Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ea1bf48f
|
||
|
|
d536448d2b
|
||
|
|
d2fd218ee4
|
||
|
|
5244caa37e
|
||
|
|
46214c55fc
|
||
|
|
7d94a15ca3
|
||
|
|
c6015e27a6
|
||
|
|
292bb29099
|
||
|
|
d853d54a06
|
||
|
|
2f5659f3ff
|
||
|
|
68657c92ea
|
||
|
|
3c1682cec3
|
||
|
|
c137ef0847
|
||
|
|
446a501053
|
||
|
|
47da19186c
|
||
|
|
4157c53a86
|
||
|
|
f0cf2d5223 | ||
|
|
9ad1887f88 | ||
|
|
f8774f51e6 | ||
|
|
31a05ae917 | ||
|
|
5dab74b14b | ||
|
|
a60e9a9c85 | ||
|
|
5277c13fb7 | ||
|
|
035cd02012 |
@@ -0,0 +1,5 @@
|
|||||||
|
FROM node:22.17.0-alpine
|
||||||
|
|
||||||
|
WORKDIR /var/www/html/
|
||||||
|
|
||||||
|
CMD npm install && npm run dev -- --host 0.0.0.0
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
FROM node:18
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
CMD npm run dev
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
FROM node:18
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
CMD npx prisma studio
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
### Docker/Podman compose.yml
|
|
||||||
```yml
|
|
||||||
services:
|
|
||||||
sarlinkportal:
|
|
||||||
image: git.shihaam.dev/sarlink/sarlink-portal
|
|
||||||
hostname: sarlink-portal
|
|
||||||
ports:
|
|
||||||
- 3000:3000
|
|
||||||
# volumes:
|
|
||||||
# - uploads:/var/www/html/public/uploads
|
|
||||||
env_file: .env
|
|
||||||
```
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
FROM node:20-slim AS builder
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN npm ci --legacy-peer-deps
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-slim
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
RUN apt update && apt install openssl -y
|
|
||||||
|
|
||||||
COPY --from=builder /var/www/html/package.json ./
|
|
||||||
COPY --from=builder /var/www/html/node_modules ./node_modules
|
|
||||||
COPY --from=builder /var/www/html/.next ./.next
|
|
||||||
COPY --from=builder /var/www/html/public ./public
|
|
||||||
|
|
||||||
VOLUME /var/www/html
|
|
||||||
|
|
||||||
ENV HOSTNAME "0.0.0.0"
|
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: ../../
|
|
||||||
dockerfile: .build/prod/bun.Dockerfile
|
|
||||||
hostname: sarlink-portal
|
|
||||||
image: git.shihaam.dev/sarlink/sarlink-portal
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
FROM oven/bun:1.1.42-debian
|
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
RUN apt update && apt install openssl -y
|
|
||||||
|
|
||||||
CMD bunx prisma studio
|
|
||||||
@@ -1,23 +1,4 @@
|
|||||||
# next auth
|
# API base for the browser → Django.
|
||||||
NEXTAUTH_SECRET=""
|
# Dev: http://localhost:8000/ (docker compose maps host :8000 → backend:5000)
|
||||||
NEXTAUTH_URL=""
|
# Prod: leave empty for same-origin (nginx proxies /api, /callback, /auth to Django)
|
||||||
## Portal API
|
VITE_API_URL=http://localhost:8000/
|
||||||
SARLINK_API_BASE_URL=""
|
|
||||||
|
|
||||||
|
|
||||||
## MIB Payments
|
|
||||||
PAYMENT_VERIFY_BASE_URL=""
|
|
||||||
|
|
||||||
## People
|
|
||||||
PEOPLE_API_URL=""
|
|
||||||
|
|
||||||
## SMS
|
|
||||||
SMS_API_URL=""
|
|
||||||
SMS_API_KEY=""
|
|
||||||
|
|
||||||
## omada
|
|
||||||
OMADA_BASE_URL=""
|
|
||||||
OMADA_SITE_ID=
|
|
||||||
OMADA_GROUP_ID=
|
|
||||||
OMADA_PROXY_API_KEY=""
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-explicit-any": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
name: Sync Gitea Mirror on Push
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync-mirror:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Trigger Gitea Mirror Sync
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.SAR_GITEA_TOKEN }}" \
|
|
||||||
-H "Accept: application/json" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
https://git.shihaam.dev/api/v1/repos/${{ vars.SAR_GITEA_REPO_OWNER }}/${{ vars.SAR_GITEA_REPO_NAME }}/mirror-sync
|
|
||||||
@@ -1,46 +1,25 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Logs
|
||||||
|
logs
|
||||||
# dependencies
|
*.log
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
.env
|
.env
|
||||||
.env.local
|
|
||||||
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
|
|
||||||
#sqlite
|
|
||||||
*.db
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
npx commitlint --edit $1
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
npm run lint
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"typescript.tsdk": "node_modules/typescript/lib"
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## <small>0.2.2 (2025-09-20)</small>
|
|
||||||
|
|
||||||
* refactor: remove table captions from all tables 🔧 ([39e8472](https://github.com/i701/sarlink-portal/commit/39e8472))
|
|
||||||
* fix: hide account information once the payment is verified/expired/cancelled 🔧 ([f2a17d5](https://github.com/i701/sarlink-portal/commit/f2a17d5))
|
|
||||||
* fix: remove pagination if there is only 1 page 🔧 ([43b8e22](https://github.com/i701/sarlink-portal/commit/43b8e22))
|
|
||||||
* fix: set 100 MVR for default wallet topup amount 🔧 ([19043aa](https://github.com/i701/sarlink-portal/commit/19043aa))
|
|
||||||
|
|
||||||
## <small>0.2.1 (2025-09-20)</small>
|
|
||||||
|
|
||||||
* fix: add release script 🔧 ([e5298aa](https://github.com/i701/sarlink-portal/commit/e5298aa))
|
|
||||||
|
|
||||||
## 0.2.0 (2025-09-20)
|
|
||||||
|
|
||||||
* fix: add error message display for general errors in OTP verification form ([25404b6](https://github.com/i701/sarlink-portal/commit/25404b6))
|
|
||||||
* fix: add logging for user data and adjust verification check in signin function ([4127035](https://github.com/i701/sarlink-portal/commit/4127035))
|
|
||||||
* fix: add missing release script ([741358e](https://github.com/i701/sarlink-portal/commit/741358e))
|
|
||||||
* fix: added missing release script to package.json 🔧 ([1399527](https://github.com/i701/sarlink-portal/commit/1399527))
|
|
||||||
* fix: correct conditional expression for progress indicator color 🐛 ([0c7f34f](https://github.com/i701/sarlink-portal/commit/0c7f34f))
|
|
||||||
* fix: correct font variable usage and improve type handling in dynamic filter ([3f92a00](https://github.com/i701/sarlink-portal/commit/3f92a00))
|
|
||||||
* fix: correct OTP type in VerifyRegistrationOTP function ([de1e76f](https://github.com/i701/sarlink-portal/commit/de1e76f))
|
|
||||||
* fix: correct user verification logic in VerifyRegistrationOTP function ([9c67386](https://github.com/i701/sarlink-portal/commit/9c67386))
|
|
||||||
* fix: enhance error handling in handleApiResponse function for better clarity 🐛 ([b90a4af](https://github.com/i701/sarlink-portal/commit/b90a4af))
|
|
||||||
* fix: enhance verification feedback in VerifyRegistrationOTP function and update UI messages ([3703b3e](https://github.com/i701/sarlink-portal/commit/3703b3e))
|
|
||||||
* fix: ensure device list is refreshed after adding a new device ([ac11fee](https://github.com/i701/sarlink-portal/commit/ac11fee))
|
|
||||||
* fix: openssl ([b9cfc55](https://github.com/i701/sarlink-portal/commit/b9cfc55))
|
|
||||||
* fix: pagination in PaymentsTable and TopupsTable components 🐛 ([783d4b3](https://github.com/i701/sarlink-portal/commit/783d4b3))
|
|
||||||
* fix: remove unnecessary JSON display of payment creation date and improve conditional styling in Mob ([71f48b1](https://github.com/i701/sarlink-portal/commit/71f48b1))
|
|
||||||
* fix: remove unused authentication imports in PaymentPage component ([886b0b1](https://github.com/i701/sarlink-portal/commit/886b0b1))
|
|
||||||
* fix: remove unused components and fix build 🐛 ([383ffdd](https://github.com/i701/sarlink-portal/commit/383ffdd))
|
|
||||||
* fix: remove unused imports from authentication pages for cleaner code ([1ae529c](https://github.com/i701/sarlink-portal/commit/1ae529c))
|
|
||||||
* fix: remove unused session retrieval in PaymentPage component ([3d12786](https://github.com/i701/sarlink-portal/commit/3d12786))
|
|
||||||
* fix: remove unused type prop from BlockDeviceDialog component ([de1e842](https://github.com/i701/sarlink-portal/commit/de1e842))
|
|
||||||
* fix: reset form after successfully adding a device in AddDeviceDialog ([067acef](https://github.com/i701/sarlink-portal/commit/067acef))
|
|
||||||
* fix: revert wut ([ff70624](https://github.com/i701/sarlink-portal/commit/ff70624))
|
|
||||||
* fix: simplify condition for displaying pending payment link in ClickableRow component 🐛 ([d1fdcc8](https://github.com/i701/sarlink-portal/commit/d1fdcc8))
|
|
||||||
* fix: streamline redirect logic for verified OTP response ([322f526](https://github.com/i701/sarlink-portal/commit/322f526))
|
|
||||||
* fix: update error messages and statuses in VerifyRegistrationOTP function for clarity ([e0e3de0](https://github.com/i701/sarlink-portal/commit/e0e3de0))
|
|
||||||
* fix: update OTP verification status message in VerifyRegistrationOTP function ([6a078d9](https://github.com/i701/sarlink-portal/commit/6a078d9))
|
|
||||||
* fix: update signin function to handle user verification response correctly ([00705bc](https://github.com/i701/sarlink-portal/commit/00705bc))
|
|
||||||
* fix: update verification error message in VerifyRegistrationOTP function ([12f9811](https://github.com/i701/sarlink-portal/commit/12f9811))
|
|
||||||
* fix: update welcome message in ApplicationLayout and simplify payment verification logic in DevicesT ([a093ab1](https://github.com/i701/sarlink-portal/commit/a093ab1))
|
|
||||||
* fix: verification response handling 🐛 ([c67b8ad](https://github.com/i701/sarlink-portal/commit/c67b8ad))
|
|
||||||
* fix(devices): fix payment amount generation by passing actual values 🐛 ([76a4e3d](https://github.com/i701/sarlink-portal/commit/76a4e3d))
|
|
||||||
* fix(sidebar): adjust transition durations for consistency 🐛 ([dcf58c4](https://github.com/i701/sarlink-portal/commit/dcf58c4))
|
|
||||||
* fix(signup-form): update header and welcome message for clarity ([db261ae](https://github.com/i701/sarlink-portal/commit/db261ae))
|
|
||||||
* fix(wallet-transactions): improve badge text color for transaction types (mobile) ([2193865](https://github.com/i701/sarlink-portal/commit/2193865))
|
|
||||||
* fix(wallet): update styling for total debit/credit display in wallet transactions table ([3943c0d](https://github.com/i701/sarlink-portal/commit/3943c0d))
|
|
||||||
* add admin checks for admin pages and run biome formating 🔨 ([9b2f2c1](https://github.com/i701/sarlink-portal/commit/9b2f2c1))
|
|
||||||
* Add Agreements page, enhance Devices and Users components with sorting and filtering options, and im ([9021f01](https://github.com/i701/sarlink-portal/commit/9021f01))
|
|
||||||
* Add AlertDialog and Badge components for user verification ([8e6f802](https://github.com/i701/sarlink-portal/commit/8e6f802))
|
|
||||||
* add auto deploy ([02e1bcb](https://github.com/i701/sarlink-portal/commit/02e1bcb))
|
|
||||||
* add auto deploy ([5e1364e](https://github.com/i701/sarlink-portal/commit/5e1364e))
|
|
||||||
* Add filter, pagination, search, and user table components ([1b43c85](https://github.com/i701/sarlink-portal/commit/1b43c85))
|
|
||||||
* add mirror to gitea ([d60dd3a](https://github.com/i701/sarlink-portal/commit/d60dd3a))
|
|
||||||
* add mirror to gitea ([9e9fab5](https://github.com/i701/sarlink-portal/commit/9e9fab5))
|
|
||||||
* add non stretched favicon ([e9c71c1](https://github.com/i701/sarlink-portal/commit/e9c71c1))
|
|
||||||
* Add payment processing and device management features ([e815da4](https://github.com/i701/sarlink-portal/commit/e815da4))
|
|
||||||
* add seed script and use postgres for db ([a0d85b1](https://github.com/i701/sarlink-portal/commit/a0d85b1))
|
|
||||||
* Add title background styling to globals.css ([762939b](https://github.com/i701/sarlink-portal/commit/762939b))
|
|
||||||
* Add user rejection and device addition functionalities ([2cbf9fb](https://github.com/i701/sarlink-portal/commit/2cbf9fb))
|
|
||||||
* attempt 2 building with docker ([a5b18e8](https://github.com/i701/sarlink-portal/commit/a5b18e8))
|
|
||||||
* attempt 4 docker ([8e068f1](https://github.com/i701/sarlink-portal/commit/8e068f1))
|
|
||||||
* attempt no 3 docker ([994956a](https://github.com/i701/sarlink-portal/commit/994956a))
|
|
||||||
* attempt to build docker again ([d72f9ae](https://github.com/i701/sarlink-portal/commit/d72f9ae))
|
|
||||||
* auto build ([fe3161e](https://github.com/i701/sarlink-portal/commit/fe3161e))
|
|
||||||
* bruh ([c79f06c](https://github.com/i701/sarlink-portal/commit/c79f06c))
|
|
||||||
* bun can build ([d5c9253](https://github.com/i701/sarlink-portal/commit/d5c9253))
|
|
||||||
* clean up dangling images after restart ([2597ac7](https://github.com/i701/sarlink-portal/commit/2597ac7))
|
|
||||||
* clean up dangling images after restart ([f9ed95a](https://github.com/i701/sarlink-portal/commit/f9ed95a))
|
|
||||||
* cleaner output ([4776952](https://github.com/i701/sarlink-portal/commit/4776952))
|
|
||||||
* cleaner output ([480f649](https://github.com/i701/sarlink-portal/commit/480f649))
|
|
||||||
* deploy in 1 ssh session ([135edf8](https://github.com/i701/sarlink-portal/commit/135edf8))
|
|
||||||
* deploy in 1 ssh session ([5bc8366](https://github.com/i701/sarlink-portal/commit/5bc8366))
|
|
||||||
* docker build works now ([5fb6f52](https://github.com/i701/sarlink-portal/commit/5fb6f52))
|
|
||||||
* down kohffa up kuraanee.. also there is 1 ui ([e841932](https://github.com/i701/sarlink-portal/commit/e841932))
|
|
||||||
* Enhance dashboard functionality with new payment and device management features ([c6f4571](https://github.com/i701/sarlink-portal/commit/c6f4571))
|
|
||||||
* Enhance device management and user experience features ([745f8d8](https://github.com/i701/sarlink-portal/commit/745f8d8))
|
|
||||||
* Enhance payment processing and device management features ([1a195d2](https://github.com/i701/sarlink-portal/commit/1a195d2))
|
|
||||||
* Enhance payment processing and device management features ([75ad431](https://github.com/i701/sarlink-portal/commit/75ad431))
|
|
||||||
* Enhance payment processing and user interaction features ([36f22c0](https://github.com/i701/sarlink-portal/commit/36f22c0))
|
|
||||||
* Enhance user management and payment processing features ([0a63e43](https://github.com/i701/sarlink-portal/commit/0a63e43))
|
|
||||||
* Enhance user verification and data validation features ([ff0eae6](https://github.com/i701/sarlink-portal/commit/ff0eae6))
|
|
||||||
* Enhance user verification and UI components ([2c67848](https://github.com/i701/sarlink-portal/commit/2c67848))
|
|
||||||
* example readme for prod deployment ([40b40ad](https://github.com/i701/sarlink-portal/commit/40b40ad))
|
|
||||||
* f all that just deploy ([1b724ca](https://github.com/i701/sarlink-portal/commit/1b724ca))
|
|
||||||
* final fix ([d14b0b3](https://github.com/i701/sarlink-portal/commit/d14b0b3))
|
|
||||||
* first commit ([7389de4](https://github.com/i701/sarlink-portal/commit/7389de4))
|
|
||||||
* fix docker build yml ([c0a71dd](https://github.com/i701/sarlink-portal/commit/c0a71dd))
|
|
||||||
* fix docker build yml ([b22a26a](https://github.com/i701/sarlink-portal/commit/b22a26a))
|
|
||||||
* fix getProfile import in PaymentId 🐛 ([361a01d](https://github.com/i701/sarlink-portal/commit/361a01d))
|
|
||||||
* fix:wut ([e2e5751](https://github.com/i701/sarlink-portal/commit/e2e5751))
|
|
||||||
* hamker magic ([ef32b74](https://github.com/i701/sarlink-portal/commit/ef32b74))
|
|
||||||
* Implement new features and enhance existing components for improved user experience ([bdf3729](https://github.com/i701/sarlink-portal/commit/bdf3729))
|
|
||||||
* Implement Omada device management and enhance payment processing ([e9d81c0](https://github.com/i701/sarlink-portal/commit/e9d81c0))
|
|
||||||
* Implement parental control features and enhance device management ([c06c4fe](https://github.com/i701/sarlink-portal/commit/c06c4fe))
|
|
||||||
* Initial commit from Create Next App ([92d9e90](https://github.com/i701/sarlink-portal/commit/92d9e90))
|
|
||||||
* installing openssl ([cd86a7a](https://github.com/i701/sarlink-portal/commit/cd86a7a))
|
|
||||||
* npx now ([bfba3cf](https://github.com/i701/sarlink-portal/commit/bfba3cf))
|
|
||||||
* optimize container build ([0e6f405](https://github.com/i701/sarlink-portal/commit/0e6f405))
|
|
||||||
* prepare for docker env ([fd4e147](https://github.com/i701/sarlink-portal/commit/fd4e147))
|
|
||||||
* prepare for docker env ([f9cac92](https://github.com/i701/sarlink-portal/commit/f9cac92))
|
|
||||||
* Refactor authentication actions and add user verification functionality ([3f68d83](https://github.com/i701/sarlink-portal/commit/3f68d83))
|
|
||||||
* Refactor authentication and dashboard components ([0322bee](https://github.com/i701/sarlink-portal/commit/0322bee))
|
|
||||||
* Refactor authentication components and enhance user session handling ([3f8bb4e](https://github.com/i701/sarlink-portal/commit/3f8bb4e))
|
|
||||||
* Refactor authentication middleware to use native fetch, update dependencies, and enhance error handl ([8ffabb1](https://github.com/i701/sarlink-portal/commit/8ffabb1))
|
|
||||||
* Refactor dashboard components and update global styles ([b91f34b](https://github.com/i701/sarlink-portal/commit/b91f34b))
|
|
||||||
* Refactor Docker setup and add Prisma Studio service ([1d6f4fa](https://github.com/i701/sarlink-portal/commit/1d6f4fa))
|
|
||||||
* Refactor Omada actions and enhance payment processing ([586c0e7](https://github.com/i701/sarlink-portal/commit/586c0e7))
|
|
||||||
* Refactor payment verification and add MAC address guide ([07349cd](https://github.com/i701/sarlink-portal/commit/07349cd))
|
|
||||||
* Refactor SMS notification handling to use environment variables ([5c167e4](https://github.com/i701/sarlink-portal/commit/5c167e4))
|
|
||||||
* Refactor UI components for improved consistency and functionality ([0f17800](https://github.com/i701/sarlink-portal/commit/0f17800))
|
|
||||||
* Refactor user actions and authentication components ([490150f](https://github.com/i701/sarlink-portal/commit/490150f))
|
|
||||||
* Refactor user verification and data handling ([a3f0759](https://github.com/i701/sarlink-portal/commit/a3f0759))
|
|
||||||
* registration verification WIP ([470e845](https://github.com/i701/sarlink-portal/commit/470e845))
|
|
||||||
* remove prisma stuff ([c799362](https://github.com/i701/sarlink-portal/commit/c799362))
|
|
||||||
* remove prisma stuff ([91563cb](https://github.com/i701/sarlink-portal/commit/91563cb))
|
|
||||||
* remove test api route ([0943898](https://github.com/i701/sarlink-portal/commit/0943898))
|
|
||||||
* remove unused imports ([fcf4f37](https://github.com/i701/sarlink-portal/commit/fcf4f37))
|
|
||||||
* remove welcome banner after 4 seconds with animation ✨ ([6c5f848](https://github.com/i701/sarlink-portal/commit/6c5f848))
|
|
||||||
* rename walet page to transaction history 🔨 ([b32b61a](https://github.com/i701/sarlink-portal/commit/b32b61a))
|
|
||||||
* Show no agreements if there is no agreement in agreement page 🔨 ([950ac67](https://github.com/i701/sarlink-portal/commit/950ac67))
|
|
||||||
* swap trusted origins for .env variables ([7acd118](https://github.com/i701/sarlink-portal/commit/7acd118))
|
|
||||||
* switch to node ([7bfdc10](https://github.com/i701/sarlink-portal/commit/7bfdc10))
|
|
||||||
* temporary fix for build and remove previous auth related code ([e8296ae](https://github.com/i701/sarlink-portal/commit/e8296ae))
|
|
||||||
* TEMPORARY FIX TO TEST BUILD ([b932fcf](https://github.com/i701/sarlink-portal/commit/b932fcf))
|
|
||||||
* Update dependencies and refactor calendar component ([df820ff](https://github.com/i701/sarlink-portal/commit/df820ff))
|
|
||||||
* update logo to omega ([cd2cff7](https://github.com/i701/sarlink-portal/commit/cd2cff7))
|
|
||||||
* Update package.json for turbopack support, enhance AppSidebar with new icons, and add Textarea and a ([2b0bd51](https://github.com/i701/sarlink-portal/commit/2b0bd51))
|
|
||||||
* update packages ([8673b87](https://github.com/i701/sarlink-portal/commit/8673b87))
|
|
||||||
* update readme ([a242500](https://github.com/i701/sarlink-portal/commit/a242500))
|
|
||||||
* update README todos ✏️ ([40bf841](https://github.com/i701/sarlink-portal/commit/40bf841))
|
|
||||||
* Update user schema and forms to include address and consent fields ([4e78ff2](https://github.com/i701/sarlink-portal/commit/4e78ff2))
|
|
||||||
* wip ([7fadcd5](https://github.com/i701/sarlink-portal/commit/7fadcd5))
|
|
||||||
* WIP feat(admin-devices): enhance device management from admin with dynamic filters and improved bloc ([01b064a](https://github.com/i701/sarlink-portal/commit/01b064a))
|
|
||||||
* wth what was yarn doing here 😭 ([967a1d2](https://github.com/i701/sarlink-portal/commit/967a1d2))
|
|
||||||
* refactor: add topup expiry filter to the Topups component and simplify expired badge display in Topu ([5de4bb9](https://github.com/i701/sarlink-portal/commit/5de4bb9))
|
|
||||||
* refactor: add tryCatch utility for error handling, update device-related components and types, and c ([aa18484](https://github.com/i701/sarlink-portal/commit/aa18484))
|
|
||||||
* refactor: enhance authentication and signup flow with new providers, update middleware matcher, and ([99c5fef](https://github.com/i701/sarlink-portal/commit/99c5fef))
|
|
||||||
* refactor: enhance error handling and add pagination to device queries ([aff9d26](https://github.com/i701/sarlink-portal/commit/aff9d26))
|
|
||||||
* refactor: enhance parental control features and improve device blocking logic 🔨 ([c90b003](https://github.com/i701/sarlink-portal/commit/c90b003))
|
|
||||||
* refactor: enhance topup payment verification with improved state management and error handling 🔨 ([013befa](https://github.com/i701/sarlink-portal/commit/013befa))
|
|
||||||
* refactor: enhance topup status display and improve conditional rendering in TopupsTable component 🔨 ([8c94c92](https://github.com/i701/sarlink-portal/commit/8c94c92))
|
|
||||||
* refactor: implement device payment verification with useActionState hook 🔨 ([1a11f1a](https://github.com/i701/sarlink-portal/commit/1a11f1a))
|
|
||||||
* refactor: implement session checking utility, enhance device queries with session validation, and im ([daab793](https://github.com/i701/sarlink-portal/commit/daab793))
|
|
||||||
* refactor: improve state management and layout in Topup components 🔨 ([a61231c](https://github.com/i701/sarlink-portal/commit/a61231c))
|
|
||||||
* refactor: migrate authentication and signup flow to use external API and improve type safety ([0fd269d](https://github.com/i701/sarlink-portal/commit/0fd269d))
|
|
||||||
* refactor: remove layout error component from dashboard ([bb5eff8](https://github.com/i701/sarlink-portal/commit/bb5eff8))
|
|
||||||
* refactor: remove unused import and improve conditional rendering in block device dialog 🔨 ([8051b00](https://github.com/i701/sarlink-portal/commit/8051b00))
|
|
||||||
* refactor: remove unused import from device-card and devices-table components ([4e16c0a](https://github.com/i701/sarlink-portal/commit/4e16c0a))
|
|
||||||
* refactor: remove unused import of checkIdOrPhone in OTP verification page ([acb0ead](https://github.com/i701/sarlink-portal/commit/acb0ead))
|
|
||||||
* refactor: remove unused imports in TopupToPay component 🔨 ([8f9b4ba](https://github.com/i701/sarlink-portal/commit/8f9b4ba))
|
|
||||||
* refactor: remove unused imports in user payments page component 🔨 ([905b4fa](https://github.com/i701/sarlink-portal/commit/905b4fa))
|
|
||||||
* refactor: remove unused user verification function and clean up user type definitions 🔨 ([78673e0](https://github.com/i701/sarlink-portal/commit/78673e0))
|
|
||||||
* refactor: reorder imports and improve variable naming in CancelPaymentButton component 🔨 ([837cc35](https://github.com/i701/sarlink-portal/commit/837cc35))
|
|
||||||
* refactor: reorganize imports and enhance admin category filtering in AppSidebar 🔨 ([1549b20](https://github.com/i701/sarlink-portal/commit/1549b20))
|
|
||||||
* refactor: reorganize imports and improve session handling in various components 🔨 ([a6d844e](https://github.com/i701/sarlink-portal/commit/a6d844e))
|
|
||||||
* refactor: replace custom authentication middleware with NextAuth, remove unused authentication pages ([32bb01b](https://github.com/i701/sarlink-portal/commit/32bb01b))
|
|
||||||
* refactor: simplify FloatingInput and Textarea components by removing unnecessary forwardRef usage 🔨 ([5809e26](https://github.com/i701/sarlink-portal/commit/5809e26))
|
|
||||||
* refactor: streamline authentication flow by removing unused code, replacing custom auth utilities wi ([ef9f032](https://github.com/i701/sarlink-portal/commit/ef9f032))
|
|
||||||
* refactor: streamline package.json and tailwind.config.ts ([9e0d2d2](https://github.com/i701/sarlink-portal/commit/9e0d2d2))
|
|
||||||
* refactor: update authentication flow to use NextAuth, replace better-auth with axios for API calls, ([020d74c](https://github.com/i701/sarlink-portal/commit/020d74c))
|
|
||||||
* refactor: update authentication flow to use PIN instead of email/password, enhance OTP verification ([dbdc1df](https://github.com/i701/sarlink-portal/commit/dbdc1df))
|
|
||||||
* refactor: update axios client import, enhance device and payment handling, and add cancel payment bu ([7e49bf1](https://github.com/i701/sarlink-portal/commit/7e49bf1))
|
|
||||||
* refactor: update cancelPayment function to use PATCH method and new endpoint 🔨 ([e984705](https://github.com/i701/sarlink-portal/commit/e984705))
|
|
||||||
* refactor: update payment types and user interface, enhance error handling, and adjust API base URL ([9e2a2f4](https://github.com/i701/sarlink-portal/commit/9e2a2f4))
|
|
||||||
* refactor: update progress component styles and add radix-ui/react-progress dependency 🔨 ([cd1dba0](https://github.com/i701/sarlink-portal/commit/cd1dba0))
|
|
||||||
* refactor: update topup status handling in DynamicFilter and TopupsTable components ✨ ([378fb40](https://github.com/i701/sarlink-portal/commit/378fb40))
|
|
||||||
* refactor: use single seperate AccountInfomation component and integrate it into DevicesToPay and Top ([fa12cd7](https://github.com/i701/sarlink-portal/commit/fa12cd7))
|
|
||||||
* refactor(sidebar): re arrange sidebar links 🔨 ([1e2329e](https://github.com/i701/sarlink-portal/commit/1e2329e))
|
|
||||||
* feat: add "Top Ups" option to sidebar with appropriate permissions and icon ✨ ([89a35a9](https://github.com/i701/sarlink-portal/commit/89a35a9))
|
|
||||||
* feat: add admin check for device and payment pages; update session type to include is_admin property ([6aea548](https://github.com/i701/sarlink-portal/commit/6aea548))
|
|
||||||
* feat: add age validation in signup and update payment verification logic ([0c093f1](https://github.com/i701/sarlink-portal/commit/0c093f1))
|
|
||||||
* feat: add custom headers configuration to next.js config for stream/suspense support ✨ ([3e3b5f1](https://github.com/i701/sarlink-portal/commit/3e3b5f1))
|
|
||||||
* feat: add custom headers configuration to next.js config for stream/suspense support ✨ ([683d857](https://github.com/i701/sarlink-portal/commit/683d857))
|
|
||||||
* feat: add Dashboard layout main Error component for error handling in dashboard ([7750c5a](https://github.com/i701/sarlink-portal/commit/7750c5a))
|
|
||||||
* feat: add disabled prop to DevicesToPay component and update button state management ✨ ([9f9f2e4](https://github.com/i701/sarlink-portal/commit/9f9f2e4))
|
|
||||||
* feat: add dual range slider component and integrate it into dynamic filter for device management ([0157ecc](https://github.com/i701/sarlink-portal/commit/0157ecc))
|
|
||||||
* feat: add example environment configuration file and update .gitignore to include .env files ([378c120](https://github.com/i701/sarlink-portal/commit/378c120))
|
|
||||||
* feat: add expiry label to ExpiryCountDown component ✨ ([4797ee8](https://github.com/i701/sarlink-portal/commit/4797ee8))
|
|
||||||
* feat: add expiry label to ExpiryCountDown component in TopupPage ✨ ([9e25da7](https://github.com/i701/sarlink-portal/commit/9e25da7))
|
|
||||||
* feat: add floating label input component for enhanced form usability ✨ ([9c1d1c5](https://github.com/i701/sarlink-portal/commit/9c1d1c5))
|
|
||||||
* feat: add getProfile function and integrate user profile retrieval in payment and layout components ([ba91d2c](https://github.com/i701/sarlink-portal/commit/ba91d2c))
|
|
||||||
* feat: add loading skeleton for devices table and improve payment processing logic ([0d578c9](https://github.com/i701/sarlink-portal/commit/0d578c9))
|
|
||||||
* feat: add loading state and full-page loader component; update payment page and application layout t ([bed426a](https://github.com/i701/sarlink-portal/commit/bed426a))
|
|
||||||
* feat: add payment method filter to payments page ✨ ([13f0e72](https://github.com/i701/sarlink-portal/commit/13f0e72))
|
|
||||||
* feat: add payment method filter to payments page ✨ ([da7d101](https://github.com/i701/sarlink-portal/commit/da7d101))
|
|
||||||
* feat: add radio filter for topup expiry ✨ ([d499353](https://github.com/i701/sarlink-portal/commit/d499353))
|
|
||||||
* feat: add radio filter for topup expiry ✨ ([154226b](https://github.com/i701/sarlink-portal/commit/154226b))
|
|
||||||
* feat: add topup management features including topup creation, cancellation, and countdown timer ✨ ([f3328f7](https://github.com/i701/sarlink-portal/commit/f3328f7))
|
|
||||||
* feat: add vendor information to device components and update related UI elements ([8438ceb](https://github.com/i701/sarlink-portal/commit/8438ceb))
|
|
||||||
* feat: enforce strict TypeScript rules and add biome configuration ([79ecb62](https://github.com/i701/sarlink-portal/commit/79ecb62))
|
|
||||||
* feat: enhance device cart with cancel feature and improve payments table filters ([2834f23](https://github.com/i701/sarlink-portal/commit/2834f23))
|
|
||||||
* feat: enhance dual-range-slider handling in DynamicFilter component ✨ ([f4c34d3](https://github.com/i701/sarlink-portal/commit/f4c34d3))
|
|
||||||
* feat: enhance error handling and improve API response management across components ([6365a70](https://github.com/i701/sarlink-portal/commit/6365a70))
|
|
||||||
* feat: enhance MAC address guide with contact button for assistance ([c705add](https://github.com/i701/sarlink-portal/commit/c705add))
|
|
||||||
* feat: enhance OTP verification flow with error handling and response checks ([0b2dcfc](https://github.com/i701/sarlink-portal/commit/0b2dcfc))
|
|
||||||
* feat: enhance payment and topup interfaces with status and expiration details ✨ ([df2b3da](https://github.com/i701/sarlink-portal/commit/df2b3da))
|
|
||||||
* feat: enhance payment retrieval with flexible query parameters and add dynamic filters to payments p ([9fe3c3b](https://github.com/i701/sarlink-portal/commit/9fe3c3b))
|
|
||||||
* feat: enhance payment retrieval with flexible query parameters and add dynamic filters to payments p ([fa0d088](https://github.com/i701/sarlink-portal/commit/fa0d088))
|
|
||||||
* feat: implement add device functionality with validation and error handling; refactor related compon ([d2b2812](https://github.com/i701/sarlink-portal/commit/d2b2812))
|
|
||||||
* feat: implement AuthLayout component and streamline OTP verification forms with enhanced messaging ([a4ffb1e](https://github.com/i701/sarlink-portal/commit/a4ffb1e))
|
|
||||||
* feat: implement checkTempIdOrPhone function and update OTP verification logic ([f3f5800](https://github.com/i701/sarlink-portal/commit/f3f5800))
|
|
||||||
* feat: implement topup functionality with create, get, and cancel operations ✨ ([ee461bb](https://github.com/i701/sarlink-portal/commit/ee461bb))
|
|
||||||
* feat: implement user profile page and integrate profile fetching logic in AccountPopover and Applica ([a20c939](https://github.com/i701/sarlink-portal/commit/a20c939))
|
|
||||||
* feat: implement user registration and OTP verification flow with backend integration ([0f9d110](https://github.com/i701/sarlink-portal/commit/0f9d110))
|
|
||||||
* feat: implement user verification and rejection functionality with improved error handling ✨ ([255c03a](https://github.com/i701/sarlink-portal/commit/255c03a))
|
|
||||||
* feat: integrate backend mobile login and enhance user verification flow ([2cb2059](https://github.com/i701/sarlink-portal/commit/2cb2059))
|
|
||||||
* feat: integrate checkTempIdOrPhone in signup logic to validate temporary phone numbers ([923887b](https://github.com/i701/sarlink-portal/commit/923887b))
|
|
||||||
* feat: update authentication layout and forms for improved user experience; add new dependencies and ([406733b](https://github.com/i701/sarlink-portal/commit/406733b))
|
|
||||||
* feat: update cancelTopup API call to use PATCH method and enhance success message with topup details ([b9d8e56](https://github.com/i701/sarlink-portal/commit/b9d8e56))
|
|
||||||
* feat: update signup and OTP verification forms to enhance error handling and state management ([1e023eb](https://github.com/i701/sarlink-portal/commit/1e023eb))
|
|
||||||
* feat: update Topup interface with status and paid_at fields; modify table display for topup details ([ef7121f](https://github.com/i701/sarlink-portal/commit/ef7121f))
|
|
||||||
* feat(admin-topup): add description field to topup form and validation for amount ([3da668a](https://github.com/i701/sarlink-portal/commit/3da668a))
|
|
||||||
* feat(admin-topup): add description field to topup request payload ([a717c3d](https://github.com/i701/sarlink-portal/commit/a717c3d))
|
|
||||||
* feat(admin): add admin payment tables + filters✨ ([c34285c](https://github.com/i701/sarlink-portal/commit/c34285c))
|
|
||||||
* feat(admin): add usertable and update next config ✨ ([780239d](https://github.com/i701/sarlink-portal/commit/780239d))
|
|
||||||
* feat(agreements): implement agreement fetching and display with error handling ([171b1d4](https://github.com/i701/sarlink-portal/commit/171b1d4))
|
|
||||||
* feat(devices): add proper filter handling and update shadcn 🔨 ([59adaaf](https://github.com/i701/sarlink-portal/commit/59adaaf))
|
|
||||||
* feat(filters): replace existing filters with dynamic filters for improved user and device management ([9926de9](https://github.com/i701/sarlink-portal/commit/9926de9))
|
|
||||||
* feat(portal-ui): enhance user and device information display in admin and user devices tables ✨ ([3cd3bba](https://github.com/i701/sarlink-portal/commit/3cd3bba))
|
|
||||||
* feat(topups): add support for fetching all topups in getTopups function ✨ ([aedf7cd](https://github.com/i701/sarlink-portal/commit/aedf7cd))
|
|
||||||
* feat(user-agreement): implement user agreement upload functionality and update related components ✨ ([c2578f1](https://github.com/i701/sarlink-portal/commit/c2578f1))
|
|
||||||
* feat(user-topups): add user topups page with dynamic filtering and admin table integration ✨ ([1f6fe7d](https://github.com/i701/sarlink-portal/commit/1f6fe7d))
|
|
||||||
* feat(user-update-form): display field errors in user update form ✨ ([17aa65a](https://github.com/i701/sarlink-portal/commit/17aa65a))
|
|
||||||
* feat(user-verification): implement user verification functionality and update dialog UI ✨ ([dc3b5f9](https://github.com/i701/sarlink-portal/commit/dc3b5f9))
|
|
||||||
* feat(user): add admin topup functionality in user details page ✨ ([644e4f7](https://github.com/i701/sarlink-portal/commit/644e4f7))
|
|
||||||
* feat(user): implement user update functionality and enhance verification page UI ✨ ([8fac07b](https://github.com/i701/sarlink-portal/commit/8fac07b))
|
|
||||||
* feat(verify-user): add link to view user agreement and improve layout for user information ([eadd790](https://github.com/i701/sarlink-portal/commit/eadd790))
|
|
||||||
* feat(wallet): implement wallet transactions table and filtering options ✨ ([e0b76bb](https://github.com/i701/sarlink-portal/commit/e0b76bb))
|
|
||||||
* feat(wallet): update transaction type from CREDIT to TOPUP and display total debit/credit amounts ✨ ([e9af120](https://github.com/i701/sarlink-portal/commit/e9af120))
|
|
||||||
* chore: update package.json ♻️ ([71fc914](https://github.com/i701/sarlink-portal/commit/71fc914))
|
|
||||||
* chore: update package.json to add millify dependency and remove unnecessary newline ([f7122cb](https://github.com/i701/sarlink-portal/commit/f7122cb))
|
|
||||||
* chore: update README with new todos 🔧 ([fb3ad13](https://github.com/i701/sarlink-portal/commit/fb3ad13))
|
|
||||||
* chore: upgrade to tailwind v4 and add a generic filter for dynamic filter handling ([11ac852](https://github.com/i701/sarlink-portal/commit/11ac852))
|
|
||||||
* chore(package-lock): update brace-expansion to version 1.1.12 and form-data to version 4.0.4 ([5fda723](https://github.com/i701/sarlink-portal/commit/5fda723))
|
|
||||||
* chore(README): update task list to reflect completed features in parental control and admin controls ([8bcf481](https://github.com/i701/sarlink-portal/commit/8bcf481))
|
|
||||||
* chore(user-payments-table): remove unused imports 🔧 ([d7b8e4e](https://github.com/i701/sarlink-portal/commit/d7b8e4e))
|
|
||||||
* chore(user-update-form): remove unused import for cleaner code 🔧 ([7a02cb2](https://github.com/i701/sarlink-portal/commit/7a02cb2))
|
|
||||||
* bug: fix payment status display and force timezones in creation date in PaymentsTable and DevicesTo ([27a0b5d](https://github.com/i701/sarlink-portal/commit/27a0b5d))
|
|
||||||
@@ -1,47 +1,32 @@
|
|||||||
This is a web portal for SAR Link customers.
|
# React + TypeScript + Vite
|
||||||
# Todos
|
|
||||||
## Layout
|
|
||||||
- [ ] Auto hide the welcome banner after 5 seconds
|
|
||||||
|
|
||||||
## User Menu
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
### Devices
|
|
||||||
- [x] Add mac vendor validation for adding devices
|
|
||||||
- [x] Add all the filters for devices table (mobile responsive)
|
|
||||||
- [x] Add cancel feature to selected devices floating button
|
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
### Payments
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
- [x] Show payments table
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
- [x] Add all the filters for payment table (mobile responsive)
|
|
||||||
- [x] add slider range filter
|
|
||||||
- [ ] Fix bill formula linking for generated payments
|
|
||||||
|
|
||||||
### Parental Control
|
## React Compiler
|
||||||
- [x] Fix block device feature
|
|
||||||
- [x] Add all the filters for parental control table (mobile responsive)
|
|
||||||
- [x] Disable blocking if payment is pending or omit from the table if device payment is pending
|
|
||||||
|
|
||||||
### Topups
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
- [ ] add textarea in topup dialog for admin. if no detail given generate on backend otherwise take the text from text area
|
|
||||||
|
|
||||||
### Agreements
|
## Expanding the Oxlint configuration
|
||||||
- [x] Implement file upload for admin side
|
|
||||||
- [ ] Add customer relavant documents in a grid view
|
|
||||||
|
|
||||||
## Admin Controls
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
### Users
|
|
||||||
- [x] Show users table
|
|
||||||
- [x] handle verify api no response case
|
|
||||||
- [x] Add all relavant filters for users table
|
|
||||||
- [x] Verify or reject users with a custom message
|
|
||||||
- [ ] Add functionality to send custom sms to users in user:id page
|
|
||||||
|
|
||||||
### User Devices
|
```json
|
||||||
- [x] Block or unblock from admin with custom message
|
{
|
||||||
- [x] Blocking devices for client and admin using useActionState instead of client side button onClick handlers
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
- [x] Show the devices table
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
- [x] Add all relevant filters for user devices table
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### User Payments
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
- [x] Show user payments table
|
|
||||||
- [x] Add relevant filters for user payments table
|
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { signUpFormSchema } from "@/lib/schemas";
|
|
||||||
import {
|
|
||||||
backendRegister,
|
|
||||||
checkIdOrPhone,
|
|
||||||
checkTempIdOrPhone,
|
|
||||||
} from "@/queries/authentication";
|
|
||||||
import { handleApiResponse, tryCatch } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
phoneNumber: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[7|9][0-9]{2}-[0-9]{4}$/, "Please enter a valid phone number"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FilterUserResponse = {
|
|
||||||
ok: boolean;
|
|
||||||
verified: boolean;
|
|
||||||
};
|
|
||||||
export type FilterTempUserResponse = {
|
|
||||||
ok: boolean;
|
|
||||||
otp_verified: boolean;
|
|
||||||
t_verified: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function signin(_previousState: ActionState, formData: FormData) {
|
|
||||||
const phoneNumber = formData.get("phoneNumber") as string;
|
|
||||||
const result = formSchema.safeParse({ phoneNumber });
|
|
||||||
console.log(phoneNumber);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
return {
|
|
||||||
message: result.error.errors[0].message, // Get the error message from Zod
|
|
||||||
status: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!phoneNumber) {
|
|
||||||
return {
|
|
||||||
message: "Please enter a phone number",
|
|
||||||
status: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const FORMATTED_MOBILE_NUMBER: string = `${phoneNumber.split("-").join("")}`;
|
|
||||||
console.log({ FORMATTED_MOBILE_NUMBER });
|
|
||||||
|
|
||||||
const user = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/filter/?mobile=${FORMATTED_MOBILE_NUMBER}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const userData = (await user.json()) as FilterUserResponse;
|
|
||||||
console.log({ userData });
|
|
||||||
if (!userData.ok) {
|
|
||||||
redirect(`/auth/signup?phone_number=${phoneNumber}`);
|
|
||||||
}
|
|
||||||
if (!userData.verified) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
"Your account is on pending verification. Please wait for a response from admin or contact shihaam.",
|
|
||||||
status: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendOTPResponse = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/auth/mobile/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
mobile: FORMATTED_MOBILE_NUMBER,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const otpResponse = await sendOTPResponse.json();
|
|
||||||
console.log("otpResponse", otpResponse);
|
|
||||||
|
|
||||||
redirect(`/auth/verify-otp?phone_number=${FORMATTED_MOBILE_NUMBER}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActionState = {
|
|
||||||
status?: string;
|
|
||||||
payload?: FormData;
|
|
||||||
errors?: z.typeToFlattenedError<
|
|
||||||
{
|
|
||||||
id_card: string;
|
|
||||||
phone_number: string;
|
|
||||||
name: string;
|
|
||||||
atoll_id: string;
|
|
||||||
island_id: string;
|
|
||||||
address: string;
|
|
||||||
dob: Date;
|
|
||||||
terms: string;
|
|
||||||
policy: string;
|
|
||||||
accNo: string;
|
|
||||||
},
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
db_error?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function signup(_actionState: ActionState, formData: FormData) {
|
|
||||||
const data = Object.fromEntries(formData.entries());
|
|
||||||
const parsedData = signUpFormSchema.safeParse(data);
|
|
||||||
// get phone number from /signup?phone_number=999-1231
|
|
||||||
|
|
||||||
console.log("DATA ON SERVER SIDE", data);
|
|
||||||
|
|
||||||
if (!parsedData.success) {
|
|
||||||
return {
|
|
||||||
message: "Invalid form data",
|
|
||||||
payload: formData,
|
|
||||||
errors: parsedData.error.flatten(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const age =
|
|
||||||
new Date().getFullYear() - new Date(parsedData.data.dob).getFullYear();
|
|
||||||
if (age < 18) {
|
|
||||||
return {
|
|
||||||
message: "You must be at least 18 years old to register.",
|
|
||||||
payload: formData,
|
|
||||||
db_error: "dob",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const idCardExists = await checkIdOrPhone({
|
|
||||||
id_card: parsedData.data.id_card,
|
|
||||||
});
|
|
||||||
if (idCardExists.ok) {
|
|
||||||
return {
|
|
||||||
message: "ID card already exists.",
|
|
||||||
payload: formData,
|
|
||||||
db_error: "id_card",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneNumberExists = await checkIdOrPhone({
|
|
||||||
phone_number: parsedData.data.phone_number,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tempPhoneNumberExists = await checkTempIdOrPhone({
|
|
||||||
phone_number: parsedData.data.phone_number,
|
|
||||||
});
|
|
||||||
if (phoneNumberExists.ok || tempPhoneNumberExists.ok) {
|
|
||||||
return {
|
|
||||||
message: "Phone number already exists.",
|
|
||||||
payload: formData,
|
|
||||||
db_error: "phone_number",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const [signupError, signupResponse] = await tryCatch(
|
|
||||||
backendRegister({
|
|
||||||
payload: {
|
|
||||||
firstname: parsedData.data.name.split(" ")[0],
|
|
||||||
lastname: parsedData.data.name.split(" ").slice(1).join(" "),
|
|
||||||
username: parsedData.data.phone_number,
|
|
||||||
address: parsedData.data.address,
|
|
||||||
id_card: parsedData.data.id_card,
|
|
||||||
dob: new Date(parsedData.data.dob).toISOString().split("T")[0],
|
|
||||||
mobile: parsedData.data.phone_number,
|
|
||||||
island: Number.parseInt(parsedData.data.island_id),
|
|
||||||
atoll: Number.parseInt(parsedData.data.atoll_id),
|
|
||||||
acc_no: parsedData.data.accNo,
|
|
||||||
terms_accepted: parsedData.data.terms,
|
|
||||||
policy_accepted: parsedData.data.policy,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (signupError) {
|
|
||||||
return {
|
|
||||||
message: signupError.message,
|
|
||||||
payload: formData,
|
|
||||||
db_error: "phone_number",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
console.log("SIGNUP RESPONSE", signupResponse);
|
|
||||||
|
|
||||||
redirect(
|
|
||||||
`/auth/verify-otp-registration?phone_number=${encodeURIComponent(signupResponse.t_username)}`,
|
|
||||||
);
|
|
||||||
return { message: "User created successfully", error: "success" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sendOtp = async (phoneNumber: string, code: string) => {
|
|
||||||
// Implement sending OTP code via SMS
|
|
||||||
console.log("Send OTP server fn", phoneNumber, code);
|
|
||||||
const respose = await fetch(`${process.env.SMS_API_BASE_URL}/api/sms`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${process.env.SMS_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
check_delivery: false,
|
|
||||||
number: phoneNumber,
|
|
||||||
message: `Your OTP code is ${code}`,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await respose.json();
|
|
||||||
console.log(data);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function backendMobileLogin({ mobile }: { mobile: string }) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/auth/mobile/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
mobile,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleApiResponse<{ detail: string }>(response, "backendMobileLogin");
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
type CreateClientProps = {
|
|
||||||
group_settings_id: string;
|
|
||||||
address1: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postal_code: string;
|
|
||||||
country_id: string;
|
|
||||||
address2: string;
|
|
||||||
contacts: Contact;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Contact = {
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
send_email: boolean;
|
|
||||||
custom_value1: string;
|
|
||||||
custom_value2: string;
|
|
||||||
custom_value3: string;
|
|
||||||
};
|
|
||||||
export async function CreateClient({
|
|
||||||
group_settings_id = "",
|
|
||||||
address1 = "",
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
postal_code = "",
|
|
||||||
country_id = "462",
|
|
||||||
address2,
|
|
||||||
contacts,
|
|
||||||
}: CreateClientProps) {
|
|
||||||
const response = await fetch(
|
|
||||||
"https://finance-staging.baraveli.dev/api/v1/clients",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-token": `${process.env.NINJA_API_KEY}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
group_settings_id,
|
|
||||||
address1,
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
postal_code,
|
|
||||||
country_id,
|
|
||||||
address2,
|
|
||||||
contacts: [
|
|
||||||
{
|
|
||||||
first_name: contacts.first_name,
|
|
||||||
last_name: contacts.last_name,
|
|
||||||
email: contacts.email || "",
|
|
||||||
phone: contacts.phone,
|
|
||||||
send_email: contacts.send_email,
|
|
||||||
custom_value1: contacts.custom_value1,
|
|
||||||
custom_value2: contacts.custom_value2,
|
|
||||||
custom_value3: contacts.custom_value3 || "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
console.log(data.data.contacts);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import type { GroupProfile, MacAddress, OmadaResponse } from "@/lib/types";
|
|
||||||
import { formatMacAddress } from "@/lib/utils";
|
|
||||||
|
|
||||||
async function fetchOmadaGroupProfiles(siteId: string): Promise<OmadaResponse> {
|
|
||||||
if (!siteId) {
|
|
||||||
throw new Error("siteId is a required parameter");
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl: string = process.env.OMADA_BASE_URL || "";
|
|
||||||
const url: string = `${baseUrl}/api/v2/sites/${siteId}/setting/profiles/groups`;
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
"X-API-key": process.env.OMADA_PROXY_API_KEY || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response: Response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: headers,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: OmadaResponse = await response.json();
|
|
||||||
if (data.errorCode !== 0) {
|
|
||||||
throw new Error(`Error fetching group profiles: ${data.msg}`);
|
|
||||||
}
|
|
||||||
console.log({ data });
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching Omada group profiles:", error);
|
|
||||||
throw error instanceof Error ? error : new Error("Unknown error occurred");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { fetchOmadaGroupProfiles };
|
|
||||||
|
|
||||||
export async function addDevicesToGroup({
|
|
||||||
siteId,
|
|
||||||
groupId,
|
|
||||||
newDevices,
|
|
||||||
}: {
|
|
||||||
siteId?: string;
|
|
||||||
groupId?: string;
|
|
||||||
newDevices: MacAddress[];
|
|
||||||
}) {
|
|
||||||
if (!siteId || !groupId) {
|
|
||||||
throw new Error("omadacId, siteId, and groupId are required parameters");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch the existing group profiles
|
|
||||||
const groupProfiles: OmadaResponse = await fetchOmadaGroupProfiles(siteId);
|
|
||||||
// console.log(groupProfiles);
|
|
||||||
// Find the group profile with the specified groupId
|
|
||||||
const groupProfile: GroupProfile | undefined =
|
|
||||||
groupProfiles.result.data.find((profile) => profile.groupId === groupId);
|
|
||||||
|
|
||||||
if (!groupProfile) {
|
|
||||||
throw new Error(`Group with ID ${groupId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new array with the existing and new devices
|
|
||||||
const updatedMacAddressList: MacAddress[] = [
|
|
||||||
...(groupProfile.macAddressList || []),
|
|
||||||
...newDevices,
|
|
||||||
];
|
|
||||||
// console.log({ updatedMacAddressList });
|
|
||||||
// Prepare the request payload
|
|
||||||
const requestBody = {
|
|
||||||
name: groupProfile.name,
|
|
||||||
type: groupProfile.type,
|
|
||||||
resource: groupProfile.resource,
|
|
||||||
ipList: groupProfile.ipList,
|
|
||||||
ipv6List: groupProfile.ipv6List,
|
|
||||||
macAddressList: updatedMacAddressList,
|
|
||||||
portList: null,
|
|
||||||
countryList: null,
|
|
||||||
portType: null,
|
|
||||||
portMaskList: null,
|
|
||||||
domainNamePort: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(requestBody);
|
|
||||||
const baseUrl = process.env.OMADA_BASE_URL || "";
|
|
||||||
const url: string = `${baseUrl}/api/v2/sites/${siteId}/setting/profiles/groups/2/${groupId}`;
|
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
"X-API-key": process.env.OMADA_PROXY_API_KEY || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
});
|
|
||||||
console.log(response.status);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adding devices to group:", error);
|
|
||||||
throw error instanceof Error ? error : new Error("Unknown error occurred");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function blockDevice({
|
|
||||||
macAddress,
|
|
||||||
type,
|
|
||||||
reason,
|
|
||||||
blockedBy = "PARENT",
|
|
||||||
}: {
|
|
||||||
macAddress: string;
|
|
||||||
type: "block" | "unblock";
|
|
||||||
reason?: string;
|
|
||||||
blockedBy?: "ADMIN" | "PARENT";
|
|
||||||
}) {
|
|
||||||
console.log("hello world asdasd");
|
|
||||||
if (!macAddress) {
|
|
||||||
throw new Error("macAddress is a required parameter");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const baseUrl: string = process.env.OMADA_BASE_URL || "";
|
|
||||||
const url: string = `${baseUrl}/api/v2/sites/${process.env.OMADA_SITE_ID}/cmd/clients/${formatMacAddress(macAddress)}/${type}`;
|
|
||||||
console.log(url);
|
|
||||||
const headers: HeadersInit = {
|
|
||||||
"X-API-key": process.env.OMADA_PROXY_API_KEY || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers,
|
|
||||||
});
|
|
||||||
console.log("blocking...");
|
|
||||||
console.log(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
// await prisma.device.update({
|
|
||||||
// where: {
|
|
||||||
// id: device?.id,
|
|
||||||
// },
|
|
||||||
// data: {
|
|
||||||
// reasonForBlocking: type === "block" ? reason : "",
|
|
||||||
// blocked: type === "block",
|
|
||||||
// blockedBy: blockedBy,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
revalidatePath("/parental-control");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error blocking device:", error);
|
|
||||||
throw error instanceof Error ? error : new Error("Unknown error occurred");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import type {
|
|
||||||
ApiError,
|
|
||||||
ApiResponse,
|
|
||||||
NewPayment,
|
|
||||||
Payment,
|
|
||||||
Topup,
|
|
||||||
} from "@/lib/backend-types";
|
|
||||||
import type { TopupResponse } from "@/lib/types";
|
|
||||||
import { handleApiResponse } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
type GenericGetResponseProps = {
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
page?: number;
|
|
||||||
[key: string]: string | number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createPayment(data: NewPayment) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
console.log("data", data);
|
|
||||||
const response = await fetch(
|
|
||||||
`${
|
|
||||||
process.env.SARLINK_API_BASE_URL // });
|
|
||||||
}/api/billing/payment/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = (await response.json()) as ApiError;
|
|
||||||
const errorMessage =
|
|
||||||
errorData.message || errorData.detail || "An error occurred.";
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
(error as ApiError & { details?: ApiError }).details = errorData; // Attach the errorData to the error object
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const payment = (await response.json()) as Payment;
|
|
||||||
revalidatePath("/devices");
|
|
||||||
return payment;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTopup(data: { amount: number }) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/topup/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleApiResponse<Topup>(response, "createTopup");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPayment({ id }: { id: string }) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/payment/${id}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = (await response.json()) as ApiError;
|
|
||||||
const errorMessage =
|
|
||||||
errorData.message || errorData.detail || "An error occurred.";
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
(error as ApiError & { details?: ApiError }).details = errorData; // Attach the errorData to the error object
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as Payment;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetPaymentProps = {
|
|
||||||
[key: string]: string | number | undefined; // Allow additional properties for flexibility
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getPayments(
|
|
||||||
params: GetPaymentProps,
|
|
||||||
allPayments = false,
|
|
||||||
) {
|
|
||||||
// Build query string from all defined params
|
|
||||||
const query = Object.entries(params)
|
|
||||||
.filter(([_, value]) => value !== undefined && value !== "")
|
|
||||||
.map(
|
|
||||||
([key, value]) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
|
||||||
)
|
|
||||||
.join("&");
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/payment/?${query}&all_payments=${allPayments}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = (await response.json()) as ApiError;
|
|
||||||
const errorMessage =
|
|
||||||
errorData.message || errorData.detail || "An error occurred.";
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
(error as ApiError & { details?: ApiError }).details = errorData; // Attach the errorData to the error object
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as ApiResponse<Payment>;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTopups(
|
|
||||||
params: GenericGetResponseProps,
|
|
||||||
all_topups = false,
|
|
||||||
) {
|
|
||||||
// Build query string from all defined params
|
|
||||||
const query = Object.entries(params)
|
|
||||||
.filter(([_, value]) => value !== undefined && value !== "")
|
|
||||||
.map(
|
|
||||||
([key, value]) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
|
||||||
)
|
|
||||||
.join("&");
|
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/topup/?${query}&all_topups=${all_topups}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleApiResponse<ApiResponse<Topup>>(response, "getTopups");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTopup({ id }: { id: string }) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/topup/${id}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return handleApiResponse<Topup>(response, "getTopup");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelTopup({ id }: { id: string }) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/topup/${id}/cancel/`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleApiResponse<Topup>(response, "cancelTopup");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function cancelPayment({ id }: { id: string }) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/payment/${id}/cancel/`,
|
|
||||||
{
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return handleApiResponse<Payment>(response, "cancelPayment");
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdatePayment = {
|
|
||||||
id: string;
|
|
||||||
method: "TRANSFER" | "WALLET";
|
|
||||||
benefName?: string;
|
|
||||||
accountNo?: string;
|
|
||||||
absAmount?: string;
|
|
||||||
time?: string;
|
|
||||||
};
|
|
||||||
export async function verifyPayment({ id, method }: UpdatePayment) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/payment/${id}/verify/`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
method,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
revalidatePath("/payments/[paymentId]", "page");
|
|
||||||
return handleApiResponse<Payment>(response, "updatePayment");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VerifyDevicePaymentState = {
|
|
||||||
payment?: Payment;
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
fieldErrors: Record<string, string>;
|
|
||||||
payload?: FormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function verifyDevicePayment(
|
|
||||||
_prevState: VerifyDevicePaymentState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<VerifyDevicePaymentState> {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
// Get the payment ID and method from the form data
|
|
||||||
const paymentId = formData.get("paymentId") as string;
|
|
||||||
const method = formData.get("method") as "TRANSFER" | "WALLET";
|
|
||||||
|
|
||||||
if (!paymentId) {
|
|
||||||
return {
|
|
||||||
message: "Payment ID is required",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: { paymentId: "Payment ID is required" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!method) {
|
|
||||||
return {
|
|
||||||
message: "Payment method is required",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: { method: "Payment method is required" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/payment/${paymentId}/verify/`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
method,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await handleApiResponse<Payment>(
|
|
||||||
response,
|
|
||||||
"verifyDevicePayment",
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidatePath("/payments/[paymentId]", "page");
|
|
||||||
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
method === "WALLET"
|
|
||||||
? "Payment completed successfully using wallet!"
|
|
||||||
: "Payment verification successful!",
|
|
||||||
success: true,
|
|
||||||
fieldErrors: {},
|
|
||||||
payment: result,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
error.message || "Payment verification failed. Please try again.",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: {},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
message: "Payment verification failed.",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VerifyTopupPaymentState = {
|
|
||||||
transaction?: {
|
|
||||||
sourceBank: string;
|
|
||||||
trxDate: string;
|
|
||||||
};
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
fieldErrors: Record<string, string>;
|
|
||||||
payload?: FormData;
|
|
||||||
};
|
|
||||||
export async function verifyTopupPayment(
|
|
||||||
_prevState: VerifyTopupPaymentState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<VerifyTopupPaymentState> {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
// Get the topup ID from the form data or use a hidden input
|
|
||||||
const topupId = formData.get("topupId") as string;
|
|
||||||
|
|
||||||
if (!topupId) {
|
|
||||||
return {
|
|
||||||
message: "Topup ID is required",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: { topupId: "Topup ID is required" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/topup/${topupId}/verify/`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await handleApiResponse<TopupResponse>(
|
|
||||||
response,
|
|
||||||
"verifyTopupPayment",
|
|
||||||
);
|
|
||||||
|
|
||||||
revalidatePath("/top-ups/[topupId]", "page");
|
|
||||||
|
|
||||||
return {
|
|
||||||
message: result.message || "Topup payment verified successfully",
|
|
||||||
success: true,
|
|
||||||
fieldErrors: {},
|
|
||||||
transaction: result.transaction,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
error.message || "Please check your payment details and try again.",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: {},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
message: "Topup verification failed.",
|
|
||||||
success: false,
|
|
||||||
fieldErrors: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import type { RejectUserFormState } from "@/components/user/user-reject-dialog";
|
|
||||||
import type { ApiError } from "@/lib/backend-types";
|
|
||||||
import type { User } from "@/lib/types/user";
|
|
||||||
import { handleApiResponse } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
type VerifyUserResponse =
|
|
||||||
| {
|
|
||||||
ok: boolean;
|
|
||||||
mismatch_fields: string[] | null;
|
|
||||||
error: string | null;
|
|
||||||
detail: string | null;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
message: boolean;
|
|
||||||
};
|
|
||||||
export async function verifyUser(userId: string) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.apiToken) {
|
|
||||||
return { ok: false, error: "Not authenticated" } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/${userId}/verify/`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const body = (await r.json().catch(() => ({}))) as VerifyUserResponse & {
|
|
||||||
message?: string;
|
|
||||||
detail?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!r.ok) {
|
|
||||||
const msg = body?.message || body?.detail || "User verification failed";
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: msg,
|
|
||||||
mismatch_fields: body?.mismatch_fields || null,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, data: body } as const;
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, error: (err as Error).message } as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProfile() {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/profile/`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return handleApiResponse<User>(response, "getProfile");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rejectUser(
|
|
||||||
_prevState: RejectUserFormState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<RejectUserFormState> {
|
|
||||||
const userId = formData.get("userId") as string;
|
|
||||||
const rejection_details = formData.get("rejection_details") as string;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/${userId}/reject/`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ rejection_details: rejection_details }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(
|
|
||||||
errorData.message || errorData.detail || "Failed to reject user",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle 204 No Content response (successful deletion)
|
|
||||||
if (response.status === 204) {
|
|
||||||
revalidatePath("/users");
|
|
||||||
redirect("/users");
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/users");
|
|
||||||
const error = await response.json();
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
(error as ApiError).message ||
|
|
||||||
(error as ApiError).detail ||
|
|
||||||
"An unexpected error occurred.",
|
|
||||||
fieldErrors: {},
|
|
||||||
payload: formData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateUserFormState = {
|
|
||||||
message: string;
|
|
||||||
fieldErrors?: {
|
|
||||||
id_card?: string[];
|
|
||||||
first_name?: string[];
|
|
||||||
last_name?: string[];
|
|
||||||
dob?: string[];
|
|
||||||
mobile?: string[];
|
|
||||||
address?: string[];
|
|
||||||
};
|
|
||||||
payload?: FormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export async function updateUser(
|
|
||||||
_prevState: UpdateUserFormState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<UpdateUserFormState> {
|
|
||||||
const userId = formData.get("userId") as string;
|
|
||||||
const data: Record<string, string | number | boolean> = {};
|
|
||||||
|
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
data[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/${userId}/update/`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const isFieldErrorObject =
|
|
||||||
json &&
|
|
||||||
typeof json === "object" &&
|
|
||||||
!Array.isArray(json) &&
|
|
||||||
Object.values(json).every(
|
|
||||||
(val) => Array.isArray(val) && val.every((v) => typeof v === "string"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
json.message ||
|
|
||||||
json.detail ||
|
|
||||||
(isFieldErrorObject
|
|
||||||
? "Please correct the highlighted fields."
|
|
||||||
: "An error occurred while updating the user."),
|
|
||||||
fieldErrors: isFieldErrorObject ? json : json.field_errors || {},
|
|
||||||
payload: formData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Successful update
|
|
||||||
const updatedUser = json as User;
|
|
||||||
revalidatePath("/users/[userId]/update", "page");
|
|
||||||
revalidatePath("/users/[userId]/verify", "page");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...updatedUser,
|
|
||||||
message: "User updated successfully",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function updateUserAgreement(
|
|
||||||
_prevState: UpdateUserFormState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<UpdateUserFormState> {
|
|
||||||
const userId = formData.get("userId") as string;
|
|
||||||
// Remove userId from formData before sending to API
|
|
||||||
const apiFormData = new FormData();
|
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
if (key !== "userId") {
|
|
||||||
apiFormData.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log({ apiFormData });
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/${userId}/agreement/`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: apiFormData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log("response in update user agreement action", response);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
errorData.message ||
|
|
||||||
errorData.detail ||
|
|
||||||
"An error occurred while updating the user agreement.",
|
|
||||||
fieldErrors: errorData.field_errors || {},
|
|
||||||
payload: formData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUserAgreement = (await response.json()) as { agreement: string };
|
|
||||||
revalidatePath("/users/[userId]/update", "page");
|
|
||||||
revalidatePath("/users/[userId]/verify", "page");
|
|
||||||
revalidatePath("/users/[userId]/agreement", "page");
|
|
||||||
|
|
||||||
return {
|
|
||||||
...updatedUserAgreement,
|
|
||||||
message: "User agreement updated successfully",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AddTopupFormState = {
|
|
||||||
status: boolean;
|
|
||||||
message: string;
|
|
||||||
fieldErrors?: {
|
|
||||||
amount?: string[];
|
|
||||||
description?: string[];
|
|
||||||
};
|
|
||||||
payload?: FormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function adminUserTopup(
|
|
||||||
_prevState: AddTopupFormState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<AddTopupFormState> {
|
|
||||||
const user_id = formData.get("user_id") as string;
|
|
||||||
const amount = formData.get("amount") as string;
|
|
||||||
const description = formData.get("description") as string;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
|
|
||||||
if (!amount) {
|
|
||||||
return {
|
|
||||||
status: false,
|
|
||||||
message: "Amount is required",
|
|
||||||
fieldErrors: { amount: ["Amount is required"], description: [] },
|
|
||||||
payload: formData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/admin-topup/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
amount: Number.parseInt(amount),
|
|
||||||
user_id: Number.parseInt(user_id),
|
|
||||||
description,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
return {
|
|
||||||
status: false,
|
|
||||||
message:
|
|
||||||
errorData.message ||
|
|
||||||
errorData.detail ||
|
|
||||||
"An error occurred while topping up the user.",
|
|
||||||
fieldErrors: {},
|
|
||||||
payload: formData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/users/[userId]/topup", "page");
|
|
||||||
return {
|
|
||||||
status: true,
|
|
||||||
message: "User topped up successfully",
|
|
||||||
fieldErrors: {},
|
|
||||||
payload: formData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import SignUpForm from "@/components/auth/signup-form";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function SignupPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ phone_number: string }>;
|
|
||||||
}) {
|
|
||||||
const phone_number = (await searchParams).phone_number;
|
|
||||||
console.log({ phone_number });
|
|
||||||
if (!phone_number) {
|
|
||||||
return redirect("/auth/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SignUpForm />;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import VerifyRegistrationOTPForm from "@/components/auth/verify-registration-otp-form";
|
|
||||||
import ClientErrorMessage from "@/components/client-error-message";
|
|
||||||
import { checkTempIdOrPhone } from "@/queries/authentication";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function VerifyRegistrationOTP({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ phone_number: string }>;
|
|
||||||
}) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (session) {
|
|
||||||
// If the user is already logged in, redirect them to the home page
|
|
||||||
return redirect("/");
|
|
||||||
}
|
|
||||||
const phone_number = (await searchParams).phone_number;
|
|
||||||
if (!phone_number) {
|
|
||||||
return redirect("/login");
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
"phone number from server page params (verify otp page)",
|
|
||||||
phone_number,
|
|
||||||
);
|
|
||||||
const [error, response] = await tryCatch(
|
|
||||||
checkTempIdOrPhone({ phone_number }),
|
|
||||||
);
|
|
||||||
if (error) {
|
|
||||||
console.log("Error in checkIdOrPhone", error);
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
if (response.otp_verified) redirect("/auth/signin");
|
|
||||||
return <VerifyRegistrationOTPForm phone_number={phone_number} />;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import VerifyOTPForm from "@/components/auth/verify-otp-form";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function VerifyOTP({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ phone_number: string }>;
|
|
||||||
}) {
|
|
||||||
const phone_number = (await searchParams).phone_number;
|
|
||||||
if (!phone_number) {
|
|
||||||
return redirect("/login");
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
"phone number from server page params (verify otp page)",
|
|
||||||
phone_number,
|
|
||||||
);
|
|
||||||
|
|
||||||
return <VerifyOTPForm phone_number={phone_number} />;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { getProfile } from "@/actions/user-actions";
|
|
||||||
import { AgreementCard } from "@/components/agreement-card";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
export default async function Agreements() {
|
|
||||||
const [error, profile] = await tryCatch(getProfile());
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center border rounded-md border-dashed font-bold title-bg py-4 px-2 mb-4">
|
|
||||||
<h3 className="text-sarLinkOrange text-2xl">Agreements</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1">
|
|
||||||
{error ? (
|
|
||||||
<div className="text-red-500">
|
|
||||||
An error occurred while fetching agreements: {error.message}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{profile.agreement ? (
|
|
||||||
<AgreementCard agreement={profile.agreement} />
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500">No agreement found.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import FullPageLoader from "@/components/full-page-loader";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <FullPageLoader />;
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function DevicesTableSkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<Table className="overflow-scroll">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Device Name</TableHead>
|
|
||||||
<TableHead>MAC Address</TableHead>
|
|
||||||
<TableHead>#</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="overflow-scroll">
|
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
|
||||||
<TableRow key={`${i + 1}`}>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="w-full h-10 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="w-full h-10 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="w-full h-10 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2}>
|
|
||||||
<Skeleton className="w-full h-4 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
<Skeleton className="w-20 h-4 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div className="sm:hidden my-4">
|
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
|
||||||
<DeviceCardSkeleton key={`${i + 1}`} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex text-sm justify-between items-center my-2 p-4 border rounded-md bg-gray-100",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="font-semibold flex w-full flex-col items-start gap-2 mb-2 relative">
|
|
||||||
<Skeleton className="w-32 h-6" />
|
|
||||||
<Skeleton className="w-36 h-6" />
|
|
||||||
<Skeleton className="w-32 h-4" />
|
|
||||||
<Skeleton className="w-40 h-8" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ApplicationLayout } from "@/components/auth/application-layout";
|
|
||||||
import QueryProvider from "@/providers/query-provider";
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<ApplicationLayout>
|
|
||||||
<QueryProvider>{children}</QueryProvider>
|
|
||||||
</ApplicationLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import PriceCalculator from "@/components/price-calculator";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function Pricing() {
|
|
||||||
return <PriceCalculator />;
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import ClientErrorMessage from "@/components/client-error-message";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { FloatingLabelInput } from "@/components/ui/floating-label";
|
|
||||||
import { getProfileById } from "@/queries/users";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
export default async function Profile() {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) return redirect("/auth/signin?callbackUrl=/profile");
|
|
||||||
const [error, profile] = await tryCatch(getProfileById(session?.user.id));
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "Invalid token.") redirect("/auth/signin");
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center font-bold border rounded-md border-dashed title-bg py-4 px-2 mb-4">
|
|
||||||
<h3 className="text-sarLinkOrange text-2xl">Profile</h3>
|
|
||||||
<div className="text-sarLinkOrange uppercase font-mono text-sm flex flex-col items-center rounded gap-2 py-2 px-4">
|
|
||||||
<span>Profile Status</span>
|
|
||||||
{verifiedStatus(profile?.verified ?? false)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<fieldset>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-w-4xl">
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-name"
|
|
||||||
label="Full Name"
|
|
||||||
value={`${profile?.first_name} ${profile?.last_name}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-id-card"
|
|
||||||
label="ID Card"
|
|
||||||
value={`${profile?.id_card}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-island"
|
|
||||||
label="Island"
|
|
||||||
value={`${profile?.atoll.name}. ${profile?.island.name}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-dob"
|
|
||||||
label="Date of Birth"
|
|
||||||
value={`${new Date(
|
|
||||||
profile?.dob.toString() ?? "",
|
|
||||||
).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
})}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-address"
|
|
||||||
label="Address"
|
|
||||||
value={`${profile?.address}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-mobile"
|
|
||||||
label="Phone Number"
|
|
||||||
value={`${profile?.mobile}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<FloatingLabelInput
|
|
||||||
id="floating-account"
|
|
||||||
label="Account Number"
|
|
||||||
value={`${profile?.acc_no}`}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
{/* <Suspense key={query} fallback={"loading...."}>
|
|
||||||
<TopupsTable searchParams={searchParams} />
|
|
||||||
</Suspense> */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifiedStatus(status: boolean) {
|
|
||||||
switch (status) {
|
|
||||||
case true:
|
|
||||||
return <Badge className="bg-green-500 text-white">Verified</Badge>;
|
|
||||||
case false:
|
|
||||||
return <Badge className="bg-red-500 text-white">Not Verified</Badge>;
|
|
||||||
default:
|
|
||||||
return <Badge className="bg-yellow-500 text-white">Unknown</Badge>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTopup } from "@/actions/payment";
|
|
||||||
import CancelTopupButton from "@/components/billing/cancel-topup-button";
|
|
||||||
import ExpiryCountDown from "@/components/billing/expiry-time-countdown";
|
|
||||||
import ClientErrorMessage from "@/components/client-error-message";
|
|
||||||
import TopupToPay from "@/components/topup-to-pay";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { TextShimmer } from "@/components/ui/text-shimmer";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
export default async function TopupPage({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ topupId: string }>;
|
|
||||||
}) {
|
|
||||||
const topupId = (await params).topupId;
|
|
||||||
const [error, topup] = await tryCatch(getTopup({ id: topupId }));
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "Invalid token.") redirect("/auth/signin");
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center border rounded-md border-dashed font-bold title-bg py-4 px-4 mb-4 mx-2">
|
|
||||||
<h3 className="text-sarLinkOrange text-2xl">Topup</h3>
|
|
||||||
<div className="flex flex-col gap-4 items-end w-full">
|
|
||||||
{!topup.is_expired && topup.paid && topup.status !== "PENDING" && (
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
className={cn(
|
|
||||||
"rounded-md opacity-100! uppercase font-semibold",
|
|
||||||
topup?.paid
|
|
||||||
? "text-green-900 bg-green-500/20"
|
|
||||||
: "text-inherit bg-yellow-400",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{topup.status}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{topup.status === "PENDING" && !topup.is_expired && (
|
|
||||||
<Button>
|
|
||||||
<TextShimmer>Payment Pending</TextShimmer>{" "}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!topup.paid &&
|
|
||||||
(topup.is_expired ? (
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
className="rounded-md opacity-100! uppercase font-semibold text-red-500 bg-red-500/20"
|
|
||||||
>
|
|
||||||
Topup Expired
|
|
||||||
</Button>
|
|
||||||
) : topup.status === "PENDING" ? (
|
|
||||||
<CancelTopupButton topupId={topupId} />
|
|
||||||
) : topup.status === "CANCELLED" ? (
|
|
||||||
<Button disabled>Topup Cancelled</Button>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!topup.paid && topup.status === "PENDING" && !topup.is_expired && (
|
|
||||||
<ExpiryCountDown expiryLabel="Top up" expiresAt={topup.expires_at} />
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
id="user-topup-details"
|
|
||||||
className="pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
|
|
||||||
>
|
|
||||||
<TopupToPay
|
|
||||||
disabled={
|
|
||||||
topup.paid || topup.is_expired || topup.status === "CANCELLED"
|
|
||||||
}
|
|
||||||
topup={topup || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import ClientErrorMessage from "@/components/client-error-message";
|
|
||||||
import UserAgreementForm from "@/components/user/user-agreement-form";
|
|
||||||
import { getProfileById } from "@/queries/users";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
// import {
|
|
||||||
// Select,
|
|
||||||
// SelectContent,
|
|
||||||
// SelectGroup,
|
|
||||||
// SelectItem,
|
|
||||||
// SelectLabel,
|
|
||||||
// SelectTrigger,
|
|
||||||
// SelectValue,
|
|
||||||
// } from "@/components/ui/select";
|
|
||||||
|
|
||||||
export default async function UserUpdate({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{
|
|
||||||
userId: string;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const { userId } = await params;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user?.is_admin) return redirect("/devices?page=1");
|
|
||||||
const [error, user] = await tryCatch(getProfileById(userId));
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "UNAUTHORIZED") {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
} else {
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4">
|
|
||||||
<h3 className="text-sarLinkOrange text-2xl">
|
|
||||||
Upload user user agreement
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<UserAgreementForm user={user} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import ClientErrorMessage from "@/components/client-error-message";
|
|
||||||
import UserUpdateForm from "@/components/user/user-update-form";
|
|
||||||
import { getProfileById } from "@/queries/users";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
// import {
|
|
||||||
// Select,
|
|
||||||
// SelectContent,
|
|
||||||
// SelectGroup,
|
|
||||||
// SelectItem,
|
|
||||||
// SelectLabel,
|
|
||||||
// SelectTrigger,
|
|
||||||
// SelectValue,
|
|
||||||
// } from "@/components/ui/select";
|
|
||||||
|
|
||||||
export default async function UserUpdate({
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{
|
|
||||||
userId: string;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const { userId } = await params;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user?.is_admin) return redirect("/devices?page=1");
|
|
||||||
const [error, user] = await tryCatch(getProfileById(userId));
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "UNAUTHORIZED") {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
} else {
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between text-gray-500 text-2xl font-bold title-bg py-4 px-2 mb-4">
|
|
||||||
<h3 className="text-sarLinkOrange text-2xl">Verify user</h3>
|
|
||||||
</div>
|
|
||||||
<UserUpdateForm user={user} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import DynamicFilter from "@/components/generic-filter";
|
|
||||||
import { WalletTransactionsTable } from "@/components/wallet-transactions-table";
|
|
||||||
|
|
||||||
export default async function Wallet({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{
|
|
||||||
query: string;
|
|
||||||
page: number;
|
|
||||||
sortBy: string;
|
|
||||||
status: string;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const query = (await searchParams)?.query || "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center border rounded-md border-dashed font-bold title-bg py-4 px-2 mb-4">
|
|
||||||
<h3 className="text-sarLinkOrange text-2xl">Transaction History</h3>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="wallet-filters"
|
|
||||||
className=" pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start"
|
|
||||||
>
|
|
||||||
<DynamicFilter
|
|
||||||
inputs={[
|
|
||||||
{
|
|
||||||
label: "Type",
|
|
||||||
name: "transaction_type",
|
|
||||||
type: "radio-group",
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
label: "All",
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Debit",
|
|
||||||
value: "debit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Credit",
|
|
||||||
value: "credit",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Topup Amount",
|
|
||||||
name: "amount",
|
|
||||||
type: "dual-range-slider",
|
|
||||||
min: 0,
|
|
||||||
max: 1000,
|
|
||||||
step: 10,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Suspense key={query} fallback={"loading...."}>
|
|
||||||
<WalletTransactionsTable searchParams={searchParams} />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import NextAuth from "next-auth";
|
|
||||||
|
|
||||||
const handler = NextAuth(authOptions);
|
|
||||||
export { handler as GET, handler as POST };
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
export async function GET(request: Request) {
|
|
||||||
console.log(request.url);
|
|
||||||
return Response.json({ message: "Request received" });
|
|
||||||
// try {
|
|
||||||
// // Validate API key before proceeding
|
|
||||||
// validateApiKey(request);
|
|
||||||
|
|
||||||
// const currentTime = new Date();
|
|
||||||
// const hoursSinceLastRun =
|
|
||||||
// (currentTime.getTime() - lastRunTime.getTime()) / (1000 * 60 * 60);
|
|
||||||
|
|
||||||
// // Get all active and unblocked devices with their latest payment
|
|
||||||
// const devices = await prisma.device.findMany({
|
|
||||||
// where: {
|
|
||||||
// isActive: true,
|
|
||||||
// blocked: false,
|
|
||||||
// },
|
|
||||||
// include: {
|
|
||||||
// payments: true,
|
|
||||||
// User: {
|
|
||||||
// include: {
|
|
||||||
// devices: true,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// let devicesNeedingNotification = 0;
|
|
||||||
// let devicesBlocked = 0;
|
|
||||||
|
|
||||||
// for (const device of devices) {
|
|
||||||
// let expiryDate = new Date();
|
|
||||||
|
|
||||||
// const payment = device.payments[0];
|
|
||||||
// expiryDate = addMonths(
|
|
||||||
// payment?.paidAt || new Date(),
|
|
||||||
// payment?.numberOfMonths || 0,
|
|
||||||
// );
|
|
||||||
|
|
||||||
// // Calculate notification threshold (5 days before expiry)
|
|
||||||
// const notificationThreshold = addDays(expiryDate, -5);
|
|
||||||
|
|
||||||
// const currentDate = new Date();
|
|
||||||
|
|
||||||
// console.log("device name -> ", device.name);
|
|
||||||
// console.log("paid date -> ", device.payments[0]?.paidAt);
|
|
||||||
// console.log("no of months paid -> ", device.payments[0]?.numberOfMonths);
|
|
||||||
// console.log("calculated expire date -> ", expiryDate);
|
|
||||||
// console.log("notification threshold -> ", notificationThreshold);
|
|
||||||
// console.log("current date -> ", currentDate);
|
|
||||||
|
|
||||||
// // Check if device is within notification period
|
|
||||||
// if (
|
|
||||||
// isWithinInterval(currentDate, {
|
|
||||||
// start: notificationThreshold,
|
|
||||||
// end: expiryDate,
|
|
||||||
// })
|
|
||||||
// ) {
|
|
||||||
// // Device is within 5 days of expiring
|
|
||||||
// if (device.User?.phoneNumber) {
|
|
||||||
// await sendNotifySms(
|
|
||||||
// new Date(expiryDate),
|
|
||||||
// device.User?.phoneNumber ?? "",
|
|
||||||
// device.name,
|
|
||||||
// );
|
|
||||||
// devicesNeedingNotification++;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Check if device has expired
|
|
||||||
// if (isAfter(currentDate, expiryDate)) {
|
|
||||||
// // Device has expired, block it
|
|
||||||
// // TODO: add a reason for blocking
|
|
||||||
// await blockDevice({
|
|
||||||
// macAddress: device.mac,
|
|
||||||
// type: "block",
|
|
||||||
// });
|
|
||||||
// await prisma.device.update({
|
|
||||||
// where: { id: device.id },
|
|
||||||
// data: {
|
|
||||||
// isActive: false,
|
|
||||||
// blocked: true,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// devicesBlocked++;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (hoursSinceLastRun < 24) {
|
|
||||||
// return Response.json({
|
|
||||||
// totalActiveDevices: devices.length,
|
|
||||||
// devicesChecked: {
|
|
||||||
// notified: devicesNeedingNotification,
|
|
||||||
// blocked: devicesBlocked,
|
|
||||||
// },
|
|
||||||
// message: "Check was run recently",
|
|
||||||
// nextCheckIn: `${Math.round(24 - hoursSinceLastRun)} hours`,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return Response.json({
|
|
||||||
// success: true,
|
|
||||||
// totalActiveDevices: devices.length,
|
|
||||||
// devicesChecked: {
|
|
||||||
// notified: devicesNeedingNotification,
|
|
||||||
// blocked: devicesBlocked,
|
|
||||||
// },
|
|
||||||
// runAt: currentTime,
|
|
||||||
// });
|
|
||||||
// } catch (error) {
|
|
||||||
// if (error instanceof Error) {
|
|
||||||
// if (error.message === "API key is missing") {
|
|
||||||
// return Response.json({ error: "API key is required" }, { status: 401 });
|
|
||||||
// }
|
|
||||||
// if (error.message === "Invalid API key") {
|
|
||||||
// return Response.json({ error: "Invalid API key" }, { status: 403 });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// console.error("Error in device check:", error);
|
|
||||||
// return Response.json({ error: "Failed to check devices" }, { status: 500 });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Mock function - replace with your actual SMS implementation
|
|
||||||
// async function sendNotifySms(
|
|
||||||
// expireDate: Date,
|
|
||||||
// phoneNumber: string,
|
|
||||||
// deviceName?: string,
|
|
||||||
// ) {
|
|
||||||
// const respose = await fetch(`${process.env.SMS_API_BASE_URL}/api/sms`, {
|
|
||||||
// method: "POST",
|
|
||||||
// headers: {
|
|
||||||
// "Content-Type": "application/json",
|
|
||||||
// Authorization: `Bearer ${process.env.SMS_API_KEY}`,
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// check_delivery: false,
|
|
||||||
// number: phoneNumber,
|
|
||||||
// message: `REMINDER! Your device [${deviceName}] will expire on ${new Date(expireDate)}.`,
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
// const data = await respose.json();
|
|
||||||
// console.log(data);
|
|
||||||
// return data;
|
|
||||||
//
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { logout } from "@/queries/authentication";
|
|
||||||
import type { NextAuthOptions } from "next-auth";
|
|
||||||
import type { JWT } from "next-auth/jwt";
|
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
|
||||||
pages: {
|
|
||||||
signIn: "/auth/signin",
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
strategy: "jwt",
|
|
||||||
maxAge: 30 * 60, // 30 mins
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
signOut({ token }) {
|
|
||||||
const apitoken = token.apiToken;
|
|
||||||
console.log("apitoken", apitoken);
|
|
||||||
logout({ token: apitoken as string });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
providers: [
|
|
||||||
CredentialsProvider({
|
|
||||||
name: "Credentials",
|
|
||||||
credentials: {
|
|
||||||
pin: { label: "Pin", type: "text", placeholder: "000000" },
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
const { pin } = credentials as {
|
|
||||||
pin: string;
|
|
||||||
};
|
|
||||||
console.log("pin", pin);
|
|
||||||
const res = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/callback/auth/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
token: pin,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log(res);
|
|
||||||
console.log("status", res.status);
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
console.log({ data });
|
|
||||||
switch (res.status) {
|
|
||||||
case 200:
|
|
||||||
return { ...data.user, apiToken: data.token, expiry: data.expiry };
|
|
||||||
case 400:
|
|
||||||
throw new Error(
|
|
||||||
JSON.stringify({ message: data.token[0], status: res.status }),
|
|
||||||
);
|
|
||||||
case 429:
|
|
||||||
throw new Error(
|
|
||||||
JSON.stringify({ message: data.message, status: res.status }),
|
|
||||||
);
|
|
||||||
case 403:
|
|
||||||
throw new Error(
|
|
||||||
JSON.stringify({ message: data.error, status: res.status }),
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
throw new Error(
|
|
||||||
JSON.stringify({
|
|
||||||
message: "FATAL: Unexprted Error occured!",
|
|
||||||
status: res.status,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
callbacks: {
|
|
||||||
redirect: async ({ url, baseUrl }) => {
|
|
||||||
// Allows relative callback URLs
|
|
||||||
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
|
||||||
return baseUrl;
|
|
||||||
},
|
|
||||||
session: async ({ session, token }) => {
|
|
||||||
const sanitizedToken = Object.keys(token).reduce((p, c) => {
|
|
||||||
// strip unnecessary properties
|
|
||||||
if (c !== "iat" && c !== "exp" && c !== "jti" && c !== "apiToken") {
|
|
||||||
Object.assign(p, { [c]: token[c] });
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}, {});
|
|
||||||
// session.expires = token.expiry
|
|
||||||
return {
|
|
||||||
...session,
|
|
||||||
user: sanitizedToken,
|
|
||||||
apiToken: token.apiToken,
|
|
||||||
// expires: token.expiry,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
jwt: ({ token, user }) => {
|
|
||||||
if (typeof user !== "undefined") {
|
|
||||||
// user has just signed in so the user object is populated
|
|
||||||
return user as unknown as JWT;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Provider } from "jotai";
|
|
||||||
import { ThemeProvider } from "@/providers/theme-provider";
|
|
||||||
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { Barlow, Bokor } from "next/font/google";
|
|
||||||
import NextTopLoader from "nextjs-toploader";
|
|
||||||
import { Toaster } from "sonner";
|
|
||||||
import "./globals.css";
|
|
||||||
import { AuthProvider } from "@/providers/AuthProvider";
|
|
||||||
import QueryProvider from "@/providers/query-provider";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "./auth";
|
|
||||||
const barlow = Barlow({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["100", "300", "400", "500", "600", "700", "800", "900"],
|
|
||||||
variable: "--font-barlow",
|
|
||||||
});
|
|
||||||
|
|
||||||
const bokor = Bokor({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["400"],
|
|
||||||
variable: "--font-bokor",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "SAR Link Portal",
|
|
||||||
description: "Sarlink Portal",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
return (
|
|
||||||
<html lang="en" suppressHydrationWarning>
|
|
||||||
<body
|
|
||||||
className={`${barlow.variable} ${bokor.variable} antialiased font-sans bg-gray-100 dark:bg-black`}
|
|
||||||
>
|
|
||||||
<AuthProvider session={session || undefined}>
|
|
||||||
<Provider>
|
|
||||||
<NextTopLoader color="#f49d1b" showSpinner={false} zIndex={9999} />
|
|
||||||
<Toaster richColors />
|
|
||||||
<ThemeProvider
|
|
||||||
attribute="class"
|
|
||||||
defaultTheme="system"
|
|
||||||
enableSystem
|
|
||||||
disableTransitionOnChange
|
|
||||||
>
|
|
||||||
<QueryProvider>{children}</QueryProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</Provider>
|
|
||||||
</AuthProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
// @ts-expect-error importing unused types are required here
|
|
||||||
import NextAuth, { DefaultSession, Session, type User } from "next-auth";
|
|
||||||
|
|
||||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
|
||||||
declare module "next-auth" {
|
|
||||||
/**
|
|
||||||
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
apiToken?: string;
|
|
||||||
name?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
image?: string | null;
|
|
||||||
user?: User & {
|
|
||||||
expiry?: string;
|
|
||||||
id?: number;
|
|
||||||
username?: string;
|
|
||||||
user_permissions?: { id: number; name: string }[];
|
|
||||||
id_card?: string;
|
|
||||||
mobile?: string;
|
|
||||||
wallet_balance?: number;
|
|
||||||
first_name?: string;
|
|
||||||
last_name?: string;
|
|
||||||
last_login?: string;
|
|
||||||
date_joined?: string;
|
|
||||||
is_superuser?: boolean;
|
|
||||||
is_admin?: boolean;
|
|
||||||
agreement?: string;
|
|
||||||
};
|
|
||||||
expires: ISODateString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
return redirect("/devices");
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
|
||||||
"vcs": {
|
|
||||||
"enabled": false,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": false
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"ignoreUnknown": false
|
|
||||||
},
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true,
|
|
||||||
"indentStyle": "tab"
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"suspicious": {
|
|
||||||
"noImplicitAnyLet": "error"
|
|
||||||
},
|
|
||||||
"recommended": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"javascript": {
|
|
||||||
"formatter": {
|
|
||||||
"quoteStyle": "double"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"assist": {
|
|
||||||
"enabled": true,
|
|
||||||
"actions": {
|
|
||||||
"source": {
|
|
||||||
"organizeImports": "on"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default {
|
|
||||||
extends: ['@commitlint/config-conventional']
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { HandCoins } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { getDevices } from "@/queries/devices";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
import BlockDeviceDialog from "../block-device-dialog";
|
|
||||||
import ClientErrorMessage from "../client-error-message";
|
|
||||||
import Pagination from "../pagination";
|
|
||||||
|
|
||||||
export async function AdminDevicesTable({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const resolvedParams = await searchParams;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const isAdmin = session?.user?.is_admin;
|
|
||||||
|
|
||||||
const page = Number.parseInt(resolvedParams.page as string) || 1;
|
|
||||||
const limit = 10;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
// Build params object for getDevices
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {};
|
|
||||||
for (const [key, value] of Object.entries(resolvedParams)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
apiParams[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apiParams.limit = limit;
|
|
||||||
apiParams.offset = offset;
|
|
||||||
|
|
||||||
const [error, devices] = await tryCatch(getDevices(apiParams, true));
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "UNAUTHORIZED") {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
} else {
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { meta, data } = devices;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data?.length === 0 ? (
|
|
||||||
<div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4">
|
|
||||||
<h3>No devices.</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Table className="overflow-scroll">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Device Name</TableHead>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>MAC Address</TableHead>
|
|
||||||
<TableHead>Vendor</TableHead>
|
|
||||||
<TableHead>#</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="overflow-scroll">
|
|
||||||
{data?.map((device) => (
|
|
||||||
<TableRow key={device.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<Link
|
|
||||||
className={cn(
|
|
||||||
"hover:underline font-semibold",
|
|
||||||
device.is_active ? "text-green-600" : "",
|
|
||||||
)}
|
|
||||||
href={`/devices/${device.id}`}
|
|
||||||
>
|
|
||||||
{device.name}
|
|
||||||
</Link>
|
|
||||||
{device.is_active ? (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Active until{" "}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{new Date(
|
|
||||||
device.expiry_date || "",
|
|
||||||
).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Device Inactive
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{device.has_a_pending_payment && (
|
|
||||||
<Link href={`/payments/${device.pending_payment_id}`}>
|
|
||||||
<span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
Payment Pending{" "}
|
|
||||||
<HandCoins className="animate-pulse" size={14} />
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{device.blocked_by === "ADMIN" && device.blocked && (
|
|
||||||
<div className="p-2 rounded border my-2 bg-white dark:bg-neutral-800 shadow">
|
|
||||||
<span className="font-semibold">Comment</span>
|
|
||||||
<p className="text-neutral-400">
|
|
||||||
{device?.reason_for_blocking}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
{device?.user?.name}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{device?.user?.id_card}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">{device.mac}</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{device?.vendor}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{!device.has_a_pending_payment && (
|
|
||||||
<BlockDeviceDialog
|
|
||||||
admin={isAdmin}
|
|
||||||
type={device.blocked ? "unblock" : "block"}
|
|
||||||
device={device}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-muted-foreground">
|
|
||||||
{meta?.total === 1 ? (
|
|
||||||
<p className="text-center">Total {meta?.total} device.</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-center">
|
|
||||||
Total {meta?.total} devices.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
totalPages={meta?.last_page}
|
|
||||||
currentPage={meta?.current_page}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTopups } from "@/actions/payment";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
import Pagination from "../pagination";
|
|
||||||
import { Badge } from "../ui/badge";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
|
|
||||||
export async function AdminTopupsTable({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const resolvedParams = await searchParams;
|
|
||||||
const page = Number.parseInt(resolvedParams.page as string) || 1;
|
|
||||||
const limit = 10;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
// Build params object
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {};
|
|
||||||
for (const [key, value] of Object.entries(resolvedParams)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
apiParams[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apiParams.limit = limit;
|
|
||||||
apiParams.offset = offset;
|
|
||||||
const [error, topups] = await tryCatch(getTopups(apiParams, true));
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (error.message.includes("Unauthorized")) {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
} else {
|
|
||||||
return <pre>{JSON.stringify(error, null, 2)}</pre>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { data, meta } = topups;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data?.length === 0 ? (
|
|
||||||
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
|
|
||||||
<h3>No topups yet.</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Table className="overflow-scroll">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Amount</TableHead>
|
|
||||||
<TableHead>Action</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="overflow-scroll">
|
|
||||||
{topups?.data?.map((topup) => (
|
|
||||||
<TableRow key={topup.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
{topup?.user?.name}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{topup?.user?.id_card}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="font-semibold pr-2">
|
|
||||||
{topup.paid ? (
|
|
||||||
<Badge
|
|
||||||
className="bg-green-100 dark:bg-green-700"
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{topup.status}
|
|
||||||
</Badge>
|
|
||||||
) : topup.is_expired ? (
|
|
||||||
<Badge>Expired</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">{topup.status}</Badge>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="font-semibold pr-2">
|
|
||||||
{topup.amount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
MVR
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Link
|
|
||||||
className="font-medium hover:underline"
|
|
||||||
href={`/top-ups/${topup.id}`}
|
|
||||||
>
|
|
||||||
<Button size={"sm"} variant="outline">
|
|
||||||
View Details
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="text-muted-foreground">
|
|
||||||
{meta?.total === 1 ? (
|
|
||||||
<p className="text-center">Total {meta?.total} topup.</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-center">Total {meta?.total} topups.</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<Pagination
|
|
||||||
totalPages={meta?.last_page}
|
|
||||||
currentPage={meta?.current_page}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getPayments } from "@/actions/payment";
|
|
||||||
import Pagination from "@/components/pagination";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
import ClientErrorMessage from "../client-error-message";
|
|
||||||
|
|
||||||
export async function UsersPaymentsTable({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const resolvedParams = await searchParams;
|
|
||||||
|
|
||||||
const page = Number.parseInt(resolvedParams.page as string) || 1;
|
|
||||||
const limit = 10;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
// Build params object for getDevices
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {};
|
|
||||||
for (const [key, value] of Object.entries(resolvedParams)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
apiParams[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apiParams.limit = limit;
|
|
||||||
apiParams.offset = offset;
|
|
||||||
|
|
||||||
const [error, payments] = await tryCatch(getPayments(apiParams, true));
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "UNAUTHORIZED") {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
} else {
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { meta, data } = payments;
|
|
||||||
|
|
||||||
// return <pre>{JSON.stringify(payments, null, 2)}</pre>;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data.length === 0 ? (
|
|
||||||
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
|
|
||||||
<h3>No user payments yet.</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table className="overflow-scroll">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Devices paid</TableHead>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>Amount</TableHead>
|
|
||||||
<TableHead>Duration</TableHead>
|
|
||||||
<TableHead>Payment Status</TableHead>
|
|
||||||
<TableHead>Payment Method</TableHead>
|
|
||||||
<TableHead>MIB Reference</TableHead>
|
|
||||||
<TableHead>Paid At</TableHead>
|
|
||||||
<TableHead>Action</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="overflow-scroll">
|
|
||||||
{data.map((payment) => (
|
|
||||||
<TableRow
|
|
||||||
className={`${payment.paid && "title-bg dark:bg-black"}`}
|
|
||||||
key={payment.id}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<ol className="list-disc list-inside text-sm">
|
|
||||||
{payment.devices.map((device) => (
|
|
||||||
<li
|
|
||||||
key={device.id}
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{device.name}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{/* {payment.user.id_card} */}
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
{payment?.user?.name}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{payment?.user?.id_card}
|
|
||||||
</span>
|
|
||||||
</div>{" "}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{payment.amount} MVR</TableCell>
|
|
||||||
<TableCell>{payment.number_of_months} Months</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
{payment.status === "PENDING" ? (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-yellow-100 text-black"
|
|
||||||
>
|
|
||||||
{payment.status}
|
|
||||||
</Badge>
|
|
||||||
) : payment.status === "PAID" ? (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-lime-100 text-black"
|
|
||||||
>
|
|
||||||
{payment.status}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-red-100 text-black"
|
|
||||||
>
|
|
||||||
{payment.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{payment.method}</TableCell>
|
|
||||||
<TableCell>{payment.mib_reference}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{new Date(payment.paid_at ?? "").toLocaleDateString(
|
|
||||||
"en-US",
|
|
||||||
{
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Link href={`/payments/${payment.id}`}>
|
|
||||||
<Button>Details</Button>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} className="text-muted-foreground">
|
|
||||||
{meta?.total === 1 ? (
|
|
||||||
<p className="text-center">Total {meta?.total} payment.</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-center">Total {meta?.total} payments.</p>
|
|
||||||
)}{" "}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
<Pagination
|
|
||||||
totalPages={meta?.last_page}
|
|
||||||
currentPage={meta?.current_page}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { EyeIcon } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
|
|
||||||
export function AgreementCard({ agreement }: { agreement: string }) {
|
|
||||||
return (
|
|
||||||
<Card className="w-full max-w-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Sarlink User Agreement</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
User agreement for Sarlink services.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardFooter className="flex-col gap-2">
|
|
||||||
<a target="_blank" rel="noopener noreferrer" className="w-full hover:cursor-pointer" href={agreement}>
|
|
||||||
<Button type="button" className="w-full hover:cursor-pointer">
|
|
||||||
<EyeIcon />
|
|
||||||
View Agreement
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { HandCoins } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TableCell, TableRow } from "@/components/ui/table";
|
|
||||||
import { deviceCartAtom } from "@/lib/atoms";
|
|
||||||
import type { Device } from "@/lib/backend-types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import AddDevicesToCartButton from "./add-devices-to-cart-button";
|
|
||||||
import BlockDeviceDialog from "./block-device-dialog";
|
|
||||||
export default function ClickableRow({
|
|
||||||
device,
|
|
||||||
parentalControl,
|
|
||||||
admin = false,
|
|
||||||
}: {
|
|
||||||
device: Device;
|
|
||||||
parentalControl?: boolean;
|
|
||||||
admin?: boolean;
|
|
||||||
}) {
|
|
||||||
const [devices, setDeviceCart] = useAtom(deviceCartAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={device.id}
|
|
||||||
className={cn(
|
|
||||||
(parentalControl === false && device.blocked) || device.is_active
|
|
||||||
? "cursor-not-allowed hover:bg-accent-foreground/10"
|
|
||||||
: "cursor-pointer hover:bg-muted-foreground/10",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (device.blocked) return;
|
|
||||||
if (device.is_active === true) return;
|
|
||||||
if (device.has_a_pending_payment === true) return;
|
|
||||||
if (parentalControl === true) return;
|
|
||||||
setDeviceCart((prev) =>
|
|
||||||
devices.some((d) => d.id === device.id)
|
|
||||||
? prev.filter((d) => d.id !== device.id)
|
|
||||||
: [...prev, device],
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-col items-start">
|
|
||||||
<Link
|
|
||||||
className={cn(
|
|
||||||
"hover:underline font-semibold",
|
|
||||||
device.is_active ? "text-green-600" : "",
|
|
||||||
)}
|
|
||||||
href={`/devices/${device.id}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{device.name}
|
|
||||||
</Link>
|
|
||||||
{device.is_active ? (
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Active until{" "}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{new Date(device.expiry_date || "").toLocaleDateString(
|
|
||||||
"en-US",
|
|
||||||
{
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground">Device Inactive</p>
|
|
||||||
)}
|
|
||||||
{device.has_a_pending_payment && (
|
|
||||||
<Link href={`/payments/${device.pending_payment_id}`}>
|
|
||||||
<span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
Payment Pending{" "}
|
|
||||||
<HandCoins className="animate-pulse" size={14} />
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{device.blocked_by === "ADMIN" && device.blocked && (
|
|
||||||
<div className="p-2 rounded border my-2 bg-white dark:bg-neutral-800 shadow">
|
|
||||||
<span className="font-semibold">Comment</span>
|
|
||||||
<p className="text-neutral-400">{device?.reason_for_blocking}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">{device.mac}</TableCell>
|
|
||||||
<TableCell className="font-medium">{device?.vendor}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{!parentalControl ? (
|
|
||||||
<AddDevicesToCartButton device={device} />
|
|
||||||
) : (
|
|
||||||
<BlockDeviceDialog
|
|
||||||
admin={admin}
|
|
||||||
type={device.blocked ? "unblock" : "block"}
|
|
||||||
device={device}
|
|
||||||
parentalControl={parentalControl}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { getDevices } from "@/queries/devices";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
import ClickableRow from "./clickable-row";
|
|
||||||
import ClientErrorMessage from "./client-error-message";
|
|
||||||
import DeviceCard from "./device-card";
|
|
||||||
import Pagination from "./pagination";
|
|
||||||
|
|
||||||
export async function DevicesTable({
|
|
||||||
searchParams,
|
|
||||||
parentalControl,
|
|
||||||
additionalFilters = {},
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
parentalControl?: boolean;
|
|
||||||
additionalFilters?: Record<string, string | number | boolean>;
|
|
||||||
}) {
|
|
||||||
const resolvedParams = await searchParams;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const isAdmin = session?.user?.is_admin;
|
|
||||||
|
|
||||||
const page = Number.parseInt(resolvedParams.page as string) || 1;
|
|
||||||
const limit = 10;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
// Build params object for getDevices
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {};
|
|
||||||
for (const [key, value] of Object.entries(resolvedParams)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
apiParams[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(additionalFilters)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
apiParams[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
apiParams.limit = limit;
|
|
||||||
apiParams.offset = offset;
|
|
||||||
const [error, devices] = await tryCatch(getDevices(apiParams));
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "UNAUTHORIZED") {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
} else {
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { meta, data } = devices;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data?.length === 0 ? (
|
|
||||||
<div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4">
|
|
||||||
<h3>{parentalControl ? "No active devices" : "No devices."}</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<Table className="overflow-scroll">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Device Name</TableHead>
|
|
||||||
<TableHead>MAC Address</TableHead>
|
|
||||||
<TableHead>Vendor</TableHead>
|
|
||||||
<TableHead>#</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="overflow-scroll">
|
|
||||||
{data?.map((device) => (
|
|
||||||
<ClickableRow
|
|
||||||
admin={isAdmin}
|
|
||||||
key={device.id}
|
|
||||||
device={device}
|
|
||||||
parentalControl={parentalControl}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={4} className="text-muted-foreground">
|
|
||||||
{meta?.total === 1 ? (
|
|
||||||
<p className="text-center">Total {meta?.total} device.</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-center">
|
|
||||||
Total {meta?.total} devices.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div className="sm:hidden my-4">
|
|
||||||
{data?.map((device) => (
|
|
||||||
<DeviceCard
|
|
||||||
parentalControl={parentalControl}
|
|
||||||
key={device.id}
|
|
||||||
device={device}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Pagination
|
|
||||||
totalPages={meta?.last_page}
|
|
||||||
currentPage={meta?.current_page}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Minus, Plus } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
NumberField,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export default function NumberInput({
|
|
||||||
maxAllowed,
|
|
||||||
label,
|
|
||||||
value = 100,
|
|
||||||
onChange,
|
|
||||||
className,
|
|
||||||
isDisabled,
|
|
||||||
}: {
|
|
||||||
maxAllowed?: number;
|
|
||||||
label: string;
|
|
||||||
value?: number;
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
className?: string;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (maxAllowed) {
|
|
||||||
if (value > maxAllowed) {
|
|
||||||
onChange(maxAllowed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [maxAllowed, value, onChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NumberField
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
className={cn(className)}
|
|
||||||
value={value}
|
|
||||||
minValue={0}
|
|
||||||
onChange={onChange}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-sm font-medium text-foreground">{label}</Label>
|
|
||||||
<Group className="relative inline-flex h-9 w-full items-center overflow-hidden whitespace-nowrap rounded-lg border border-input text-sm shadow-sm shadow-black/5 transition-shadow data-[focus-within]:border-ring data-disabled:opacity-50 data-focus-within:outline-none data-focus-within:ring-[3px] data-[focus-within]:ring-ring/20">
|
|
||||||
<Button
|
|
||||||
slot="decrement"
|
|
||||||
className="-ms-px flex aspect-square h-[inherit] items-center justify-center rounded-s-lg border border-input bg-background text-sm text-muted-foreground/80 transition-shadow hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Minus size={16} strokeWidth={2} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
<Input className="w-full grow bg-background px-3 py-2 text-center text-base tabular-nums text-foreground focus:outline-none" />
|
|
||||||
<Button
|
|
||||||
slot="increment"
|
|
||||||
className="-me-px flex aspect-square h-[inherit] items-center justify-center rounded-e-lg border border-input bg-background text-sm text-muted-foreground/80 transition-shadow hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Plus size={16} strokeWidth={2} aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</NumberField>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
type PaginationProps = {
|
|
||||||
totalPages: number;
|
|
||||||
currentPage: number;
|
|
||||||
maxVisible?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Pagination({
|
|
||||||
totalPages,
|
|
||||||
currentPage,
|
|
||||||
maxVisible = 4,
|
|
||||||
}: PaginationProps) {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const activePage = searchParams.get("page") ?? 1;
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [queryParams, setQueryParams] = useState<{ [key: string]: string }>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = Object.fromEntries(
|
|
||||||
Array.from(searchParams.entries()).filter(([key]) => key !== "page"),
|
|
||||||
);
|
|
||||||
setQueryParams(params);
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!searchParams.has("page")) {
|
|
||||||
router.replace(`?page=1${IncludeQueries()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function IncludeQueries() {
|
|
||||||
return Object.entries(queryParams)
|
|
||||||
.map(([key, value]) => `&${key}=${value}`)
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatePageNumbers = (): (number | string)[] => {
|
|
||||||
const halfVisible = Math.floor(maxVisible / 2);
|
|
||||||
let startPage = Math.max(currentPage - halfVisible, 1);
|
|
||||||
const endPage = Math.min(startPage + maxVisible - 1, totalPages);
|
|
||||||
|
|
||||||
if (endPage - startPage + 1 < maxVisible) {
|
|
||||||
startPage = Math.max(endPage - maxVisible + 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageNumbers: (number | string)[] = [];
|
|
||||||
|
|
||||||
if (startPage > 1) {
|
|
||||||
pageNumbers.push(1);
|
|
||||||
if (startPage > 2) pageNumbers.push("...");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = startPage; i <= endPage; i++) {
|
|
||||||
pageNumbers.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endPage < totalPages) {
|
|
||||||
if (endPage < totalPages - 1) pageNumbers.push("...");
|
|
||||||
pageNumbers.push(totalPages);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pageNumbers;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pageNumbers = generatePageNumbers();
|
|
||||||
|
|
||||||
if (totalPages <= 1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center space-x-2 my-4">
|
|
||||||
{currentPage > 1 && (
|
|
||||||
<Link href={`?page=${Number(currentPage) - 1}${IncludeQueries()}`}>
|
|
||||||
<Button variant="secondary" className="flex items-center gap-2">
|
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pageNumbers.map((page) => (
|
|
||||||
<React.Fragment key={`${page}`}>
|
|
||||||
{typeof page === "number" ? (
|
|
||||||
<Link href={`?page=${page}${IncludeQueries()}`}>
|
|
||||||
<Button
|
|
||||||
variant={Number(activePage) === page ? "default" : "outline"}
|
|
||||||
>
|
|
||||||
{page}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="px-2">...</span>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{currentPage < totalPages && (
|
|
||||||
<Link href={`?page=${Number(currentPage) + 1}${IncludeQueries()}`}>
|
|
||||||
<Button variant="secondary" className="flex items-center gap-2">
|
|
||||||
<ArrowRightIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getUsers } from "@/queries/users";
|
|
||||||
import { tryCatch } from "@/utils/tryCatch";
|
|
||||||
import ClientErrorMessage from "./client-error-message";
|
|
||||||
import Pagination from "./pagination";
|
|
||||||
import { Badge } from "./ui/badge";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableFooter,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "./ui/table";
|
|
||||||
|
|
||||||
export async function UsersTable({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{
|
|
||||||
[key: string]: unknown;
|
|
||||||
}>;
|
|
||||||
}) {
|
|
||||||
const resolvedParams = await searchParams;
|
|
||||||
|
|
||||||
const page = Number.parseInt(resolvedParams.page as string) || 1;
|
|
||||||
const limit = 10;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {};
|
|
||||||
for (const [key, value] of Object.entries(resolvedParams)) {
|
|
||||||
if (value !== undefined && value !== "") {
|
|
||||||
apiParams[key] = typeof value === "number" ? value : String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apiParams.limit = limit;
|
|
||||||
apiParams.offset = offset;
|
|
||||||
const [error, users] = await tryCatch(getUsers(apiParams));
|
|
||||||
if (error) {
|
|
||||||
if (error.message === "UNAUTHORIZED") {
|
|
||||||
redirect("/auth/signin");
|
|
||||||
}
|
|
||||||
return <ClientErrorMessage message={error.message} />;
|
|
||||||
}
|
|
||||||
const { meta, data } = users;
|
|
||||||
|
|
||||||
// return null;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{users?.data.length === 0 ? (
|
|
||||||
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
|
|
||||||
<h3>No Users yet.</h3>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Table className="overflow-scroll">
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>ID Card</TableHead>
|
|
||||||
<TableHead>Atoll</TableHead>
|
|
||||||
<TableHead>Island</TableHead>
|
|
||||||
<TableHead>House Name</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Dob</TableHead>
|
|
||||||
<TableHead>Phone Number</TableHead>
|
|
||||||
<TableHead>Action</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody className="overflow-scroll">
|
|
||||||
{data.map((user) => (
|
|
||||||
<TableRow
|
|
||||||
className={`${user.verified && "title-bg dark:bg-black"}`}
|
|
||||||
key={user.id}
|
|
||||||
>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">{user.id_card}</TableCell>
|
|
||||||
<TableCell>{user.atoll?.name}</TableCell>
|
|
||||||
<TableCell>{user.island?.name}</TableCell>
|
|
||||||
<TableCell>{user.address}</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
{user.verified ? (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-lime-100 text-black"
|
|
||||||
>
|
|
||||||
Verified
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="bg-yellow-100 text-black"
|
|
||||||
>
|
|
||||||
Unverified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{new Date(user.dob ?? "").toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>{user.mobile}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link href={`/users/${user.id}/details`}>
|
|
||||||
<Button>Details</Button>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
<TableFooter>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9}>
|
|
||||||
{meta?.total === 1 ? (
|
|
||||||
<p className="text-center">Total {meta?.total} user.</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-center">Total {meta?.total} users.</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableFooter>
|
|
||||||
</Table>
|
|
||||||
<Pagination
|
|
||||||
totalPages={meta?.last_page}
|
|
||||||
currentPage={meta?.current_page}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { UserX } from "lucide-react";
|
|
||||||
import { useActionState, useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { rejectUser } from "@/actions/user-actions";
|
|
||||||
// import { Rejectuser } from "@/actions/user-actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import type { UserProfile } from "@/lib/types/user";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Textarea } from "../ui/textarea";
|
|
||||||
|
|
||||||
export type RejectUserFormState = {
|
|
||||||
message: string;
|
|
||||||
fieldErrors?: {
|
|
||||||
rejection_details?: string[];
|
|
||||||
};
|
|
||||||
payload?: FormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initialState: RejectUserFormState = {
|
|
||||||
message: "",
|
|
||||||
fieldErrors: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UserRejectDialog({ user }: { user: UserProfile }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const [state, formAction, isPending] = useActionState(
|
|
||||||
rejectUser,
|
|
||||||
initialState,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.message && state !== initialState) {
|
|
||||||
if (state.fieldErrors && Object.keys(state.fieldErrors).length > 0) {
|
|
||||||
toast.error(state.message);
|
|
||||||
} else if (!state.fieldErrors) {
|
|
||||||
toast.success("User rejected successfully!");
|
|
||||||
setOpen(false);
|
|
||||||
} else {
|
|
||||||
toast.error(state.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button disabled={isPending} variant="destructive">
|
|
||||||
<UserX />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-muted-foreground">
|
|
||||||
Are you sure?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="py-2">
|
|
||||||
<li>
|
|
||||||
Name: {user.first_name} {user.last_name}
|
|
||||||
</li>
|
|
||||||
<li>ID Card: {user.id_card}</li>
|
|
||||||
<li>Address: {user.address}</li>
|
|
||||||
<li>
|
|
||||||
DOB:{" "}
|
|
||||||
{new Date(user.dob ?? "").toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</li>
|
|
||||||
<li>Phone Number: {user.mobile}</li>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form action={formAction}>
|
|
||||||
<div className="grid gap-4 py-4">
|
|
||||||
<div className="flex flex-col items-start gap-2">
|
|
||||||
<input type="hidden" name="userId" value={user.id} />
|
|
||||||
<Label
|
|
||||||
htmlFor="reason"
|
|
||||||
className="text-right text-muted-foreground"
|
|
||||||
>
|
|
||||||
Rejection details
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
rows={10}
|
|
||||||
name="rejection_details"
|
|
||||||
id="reason"
|
|
||||||
defaultValue={state.payload?.get("rejection_details") as string}
|
|
||||||
className={cn(
|
|
||||||
"col-span-5",
|
|
||||||
state.fieldErrors?.rejection_details && "ring-2 ring-red-500",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-red-500">
|
|
||||||
{state.fieldErrors?.rejection_details?.[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant={"destructive"} disabled={isPending} type="submit">
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { CircleDollarSign, Loader2, Wallet2 } from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { createTopup } from "@/actions/payment";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
import { WalletDrawerOpenAtom, walletTopUpValue } from "@/lib/atoms";
|
|
||||||
import type { TopupType } from "@/lib/types";
|
|
||||||
import NumberInput from "./number-input";
|
|
||||||
|
|
||||||
export function Wallet({ walletBalance }: { walletBalance: number }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [amount, setAmount] = useAtom(walletTopUpValue);
|
|
||||||
const [isOpen, setIsOpen] = useAtom(WalletDrawerOpenAtom);
|
|
||||||
const [disabled, setDisabled] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
if (pathname === "/payment") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: TopupType = {
|
|
||||||
amount: Number.parseFloat(amount.toFixed(2)),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DrawerTrigger asChild>
|
|
||||||
<Button onClick={() => setIsOpen(!isOpen)} variant="outline">
|
|
||||||
{walletBalance} MVR
|
|
||||||
<Wallet2 />
|
|
||||||
</Button>
|
|
||||||
</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
|
||||||
<div className="mx-auto w-full max-w-sm">
|
|
||||||
<DrawerHeader>
|
|
||||||
<DrawerTitle>Wallet</DrawerTitle>
|
|
||||||
<DrawerDescription asChild>
|
|
||||||
<div>
|
|
||||||
Your wallet balance is{" "}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{new Intl.NumberFormat("en-US", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(walletBalance)}
|
|
||||||
</span>{" "}
|
|
||||||
</div>
|
|
||||||
</DrawerDescription>
|
|
||||||
</DrawerHeader>
|
|
||||||
|
|
||||||
<div className="px-4 flex flex-col gap-4">
|
|
||||||
<NumberInput
|
|
||||||
label="Set amount to top up"
|
|
||||||
value={amount}
|
|
||||||
onChange={(value) => setAmount(value)}
|
|
||||||
maxAllowed={5000}
|
|
||||||
isDisabled={amount === 0}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DrawerFooter>
|
|
||||||
<Button
|
|
||||||
onClick={async () => {
|
|
||||||
console.log(data);
|
|
||||||
setDisabled(true);
|
|
||||||
const topup = await createTopup(data);
|
|
||||||
setDisabled(false);
|
|
||||||
if (topup) {
|
|
||||||
router.push(`/top-ups/${topup.id}`);
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
} else {
|
|
||||||
toast.error("Something went wrong.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full"
|
|
||||||
disabled={amount === 0 || disabled}
|
|
||||||
>
|
|
||||||
{disabled ? (
|
|
||||||
<Loader2 className="ml-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Go to payment
|
|
||||||
<CircleDollarSign />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<DrawerClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
node:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .build/dev
|
context: .build/dev
|
||||||
dockerfile: node.Dockerfile
|
dockerfile: Dockerfile
|
||||||
hostname: sarlink-portal-dev
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html/
|
- ./:/var/www/html/
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 5173:5173
|
||||||
prisma-studio:
|
env_file:
|
||||||
build:
|
- .env
|
||||||
context: .build/dev
|
|
||||||
dockerfile: prisma.Dockerfile
|
|
||||||
hostname: sarlink-portal-studio
|
|
||||||
volumes:
|
|
||||||
- ./:/var/www/html/
|
|
||||||
ports:
|
|
||||||
- 5555:5555
|
|
||||||
database:
|
|
||||||
image: postgres:14
|
|
||||||
hostname: sarlink-portal-db
|
|
||||||
volumes:
|
|
||||||
- db-data:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: testpass123
|
|
||||||
POSTGRES_DB: mydb
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
db-data:
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# SAR Link Portal — Frontend Documentation
|
||||||
|
|
||||||
|
This folder is a **reconstruction spec** for the SAR Link Portal frontend: enough detail to rebuild the app screen-by-screen in any framework. It documents the UI, user flows, API endpoints, design system, and all hardcoded copy.
|
||||||
|
|
||||||
|
## What the app is
|
||||||
|
|
||||||
|
A customer + admin portal for an ISP (SAR Link by OmegaTech Solutions). Customers register (identity-verified against the national registry), add network devices, and pay for device subscriptions by **bank transfer (MIB)** or from a **prepaid wallet**. Admins verify users and manage devices, payments, topups, and wallet credits.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
|
||||||
|
| Doc | What's in it |
|
||||||
|
|---|---|
|
||||||
|
| [architecture.md](./architecture.md) | Stack, folder map, current (BFF) vs target (static + nginx + `API_URL`) architecture, auth model, deployment, cleanup list |
|
||||||
|
| [design-system.md](./design-system.md) | Brand color (`sarLinkOrange #f49b5b`), theme tokens, fonts (Barlow/Bokor), dark mode, status colors, reusable layout patterns, app shell |
|
||||||
|
| [navigation.md](./navigation.md) | Full sidebar menu tree (labels, icons, routes, permissions), user vs admin visibility, other nav entry points |
|
||||||
|
| [routes.md](./routes.md) | Every route/page: URL, purpose, components, guards, loading states |
|
||||||
|
| [components.md](./components.md) | Component catalog by feature area + conventions |
|
||||||
|
| [user-flows-and-state.md](./user-flows-and-state.md) | End-to-end flows (register, login, buy/pay device, topup, parental control, admin) + Jotai atoms + React Query + auth/token storage |
|
||||||
|
| [api-endpoints.md](./api-endpoints.md) | Every backend endpoint: method, path, purpose, request/response, auth, + dead-code list |
|
||||||
|
| [ui-copy.md](./ui-copy.md) | Every hardcoded string (headings, buttons, placeholders, toasts, validation, bank/brand/contacts) for i18n |
|
||||||
|
|
||||||
|
Related, outside this folder: [`../STATIC_MIGRATION_PLAN.md`](../STATIC_MIGRATION_PLAN.md) (in-progress static-export migration), `.build/prod/` (production compose + nginx).
|
||||||
|
|
||||||
|
## How to recreate the app from these docs
|
||||||
|
|
||||||
|
1. **Scaffold** the target stack (recommended: Vite + React + TanStack/React Router + Tailwind v4 + shadcn/ui `new-york` neutral + lucide). See [architecture.md](./architecture.md).
|
||||||
|
2. **Theme** it from [design-system.md](./design-system.md) — brand color, fonts, tokens, `title-bg`, status colors, dark mode.
|
||||||
|
3. **Build the shell** — sidebar ([navigation.md](./navigation.md)) + header (wallet / theme / account) + auth-page card layout ([design-system.md](./design-system.md)).
|
||||||
|
4. **Auth** — implement the login flow and token store from [user-flows-and-state.md](./user-flows-and-state.md) using `API_URL`; guard routes.
|
||||||
|
5. **Routes** — create every screen in [routes.md](./routes.md); compose from [components.md](./components.md).
|
||||||
|
6. **Data** — wire each screen to its endpoints in [api-endpoints.md](./api-endpoints.md) via React Query; carry over the response envelope + status conventions.
|
||||||
|
7. **Copy** — pull all visible text from [ui-copy.md](./ui-copy.md) (fix the noted typos; set up i18n keys).
|
||||||
|
8. **Flows** — validate the six end-to-end flows in [user-flows-and-state.md](./user-flows-and-state.md).
|
||||||
|
|
||||||
|
## Key facts to carry over
|
||||||
|
|
||||||
|
- **API base is configurable:** `API_URL=http://localhost:8000` (dev) / `https://portal.sarlink.net/api` (prod, nginx → Django). Same build, both environments.
|
||||||
|
- **Auth:** Knox token from `POST /callback/auth/`, stored in localStorage, sent as `Authorization: Token <token>`; `401` → signin.
|
||||||
|
- **Two-tier UI:** desktop tables + mobile cards for every list; filters/pagination via URL query params.
|
||||||
|
- **Payments:** device payments and wallet both verify via the same endpoint with a `method` (`TRANSFER`/`WALLET`); **MIB verification is server-side** — the frontend only sends the method and shows hardcoded bank details.
|
||||||
|
- **Admin gating:** `user.is_admin` / `user.user_permissions`.
|
||||||
|
- **Dead code exists** — don't port Omada, Invoice Ninja, next-auth routes, or the unused axios helpers (see [api-endpoints.md](./api-endpoints.md)).
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
Compiled from a full read of `app/`, `components/`, `actions/`, `queries/`, `lib/`, and config. The app is mid-migration from the Node/BFF model to a static build — docs describe **current behavior** and flag migration deltas inline.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# API Endpoints
|
||||||
|
|
||||||
|
Every backend endpoint the frontend calls. Paths are shown **after** the API base (today `SARLINK_API_BASE_URL`; in the target architecture the base becomes `API_URL` → nginx `/api`). Auth = requires `Authorization: Token <token>`.
|
||||||
|
|
||||||
|
Third-party integrations use different bases (`PERSON_VERIFY_BASE_URL`, `OMADA_BASE_URL`, hardcoded Invoice Ninja) — flagged inline.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Method | Path | Purpose | Auth |
|
||||||
|
|---|---|---|:--:|
|
||||||
|
| **Auth & onboarding** ||||
|
||||||
|
| GET | `/api/auth/users/filter/?mobile=&id_card=` | Does a permanent user exist / is verified | No |
|
||||||
|
| GET | `/api/auth/users/temp/filter/?mobile=&id_card=` | Does a pending (temp) user exist | No |
|
||||||
|
| POST | `/auth/mobile/` | Send login OTP to a mobile | No |
|
||||||
|
| POST | `/api/auth/register/` | Register a new (temp) user | No |
|
||||||
|
| POST | `/api/auth/register/verify/` | Verify registration OTP | No |
|
||||||
|
| POST | `/callback/auth/` | Exchange OTP PIN → API token + user | No |
|
||||||
|
| POST | `/auth/logout/` | Invalidate token on sign-out | Yes |
|
||||||
|
| POST | `/auth/login/` | Username/password login — **DEAD** | No |
|
||||||
|
| **Billing** ||||
|
||||||
|
| POST | `/api/billing/payment/` | Create a device payment | Yes |
|
||||||
|
| GET | `/api/billing/payment/?…&all_payments=` | List payments | Yes |
|
||||||
|
| GET | `/api/billing/payment/{id}` | Get one payment | Yes |
|
||||||
|
| PATCH | `/api/billing/payment/{id}/cancel/` | Cancel a payment | Yes |
|
||||||
|
| PUT | `/api/billing/payment/{id}/verify/` | Verify/complete payment (TRANSFER/WALLET) | Yes |
|
||||||
|
| POST | `/api/billing/topup/` | Create a wallet top-up | Yes |
|
||||||
|
| GET | `/api/billing/topup/?…&all_topups=` | List top-ups | Yes |
|
||||||
|
| GET | `/api/billing/topup/{id}` | Get one top-up | Yes |
|
||||||
|
| PATCH | `/api/billing/topup/{id}/cancel/` | Cancel a top-up | Yes |
|
||||||
|
| PUT | `/api/billing/topup/{id}/verify/` | Verify a top-up payment | Yes |
|
||||||
|
| POST | `/api/billing/admin-topup/` | Admin credits a user's wallet | Yes |
|
||||||
|
| GET | `/api/billing/wallet-transactions/?…&all_transactions=` | List wallet transactions | Yes |
|
||||||
|
| **Devices** ||||
|
||||||
|
| GET | `/api/devices/?…&all_devices=` | List devices | Yes |
|
||||||
|
| GET | `/api/devices/{id}/` | Get one device | Yes |
|
||||||
|
| POST | `/api/devices/` | Register/add a device | Yes |
|
||||||
|
| PUT | `/api/devices/{id}/block/` | Block/unblock a device | Yes |
|
||||||
|
| **Users / profile / geo** ||||
|
||||||
|
| GET | `/api/auth/atolls/` | List atolls (+ nested islands) | No |
|
||||||
|
| GET | `/api/auth/users/?…` | List users (admin) | Yes |
|
||||||
|
| GET | `/api/auth/users/{id}/` | Get a user profile by id | Yes |
|
||||||
|
| GET | `/api/auth/profile/` | Logged-in user's own profile | Yes |
|
||||||
|
| PUT | `/api/auth/users/{id}/verify/` | Admin verify a user | Yes |
|
||||||
|
| DELETE | `/api/auth/users/{id}/reject/` | Admin reject a user | Yes |
|
||||||
|
| PUT | `/api/auth/users/{id}/update/` | Update a user's details | Yes |
|
||||||
|
| PUT | `/api/auth/users/{id}/agreement/` | Upload/update agreement (multipart) | Yes |
|
||||||
|
| **Third-party** ||||
|
||||||
|
| GET | `{PERSON_VERIFY}/api/person/{idCard}` | National identity lookup | No |
|
||||||
|
| — | Omada group/block endpoints | **DEAD** (moving to RADIUS) | X-API-key |
|
||||||
|
| POST | `{ninja}/api/v1/clients` | Invoice Ninja client — **DEAD** | x-api-token |
|
||||||
|
|
||||||
|
## Auth & onboarding
|
||||||
|
|
||||||
|
- **GET `/api/auth/users/filter/`** — `signin()` (auth-actions) + `checkIdOrPhone()`. Checks a phone/ID exists & is verified before OTP; signup dupe-check. → `{ ok, verified }`.
|
||||||
|
- **GET `/api/auth/users/temp/filter/`** — `checkTempIdOrPhone()`. Pending-registration lookup. → `{ ok, otp_verified, t_verified }`.
|
||||||
|
- **POST `/auth/mobile/`** — `signin()`. Sends login OTP. Body `{ mobile }`. → `{ detail }`.
|
||||||
|
- **POST `/api/auth/register/`** — `backendRegister()` from `signup()`. Body `{ firstname, lastname, username, address, id_card, dob, mobile, island, atoll, acc_no, terms_accepted, policy_accepted }`. → `{ t_username }`.
|
||||||
|
- **POST `/api/auth/register/verify/`** — `VerifyRegistrationOTP()`. Body `{ mobile, otp }`. → `{ message, verified }`.
|
||||||
|
- **POST `/callback/auth/`** — the login token exchange (was NextAuth `authorize`, now `verify-otp-form`). Body `{ token: pin }`. → `{ user, token, expiry }`. 400/403/429 return error payloads (`token[0]` / `message`).
|
||||||
|
- **POST `/auth/logout/`** — `logout()`. Expects `204`.
|
||||||
|
- **POST `/auth/login/`** — DEAD (`login()` via `axiosInstance`, unused; login goes through `/callback/auth/`).
|
||||||
|
|
||||||
|
## Billing
|
||||||
|
|
||||||
|
`actions/payment.ts`, `queries/wallet.ts`, `actions/user-actions.ts`.
|
||||||
|
|
||||||
|
- **POST `/api/billing/payment/`** `createPayment()` — pay for cart devices. Body `{ device_ids[], number_of_months }` → `Payment`.
|
||||||
|
- **GET `/api/billing/payment/`** `getPayments()` — list; `all_payments=true` for admin. → `ApiResponse<Payment>`.
|
||||||
|
- **GET `/api/billing/payment/{id}`** `getPayment()` → `Payment`.
|
||||||
|
- **PATCH `…/payment/{id}/cancel/`** `cancelPayment()` → `Payment`.
|
||||||
|
- **PUT `…/payment/{id}/verify/`** `verifyPayment()` / `verifyDevicePayment()` — Body `{ method: "TRANSFER" | "WALLET" }`. MIB verification is **server-side**; the frontend just sends the method. → `Payment`.
|
||||||
|
- **POST `/api/billing/topup/`** `createTopup()` — Body `{ amount }` → `Topup`.
|
||||||
|
- **GET `/api/billing/topup/`** `getTopups()` — `all_topups=true` for admin. → `ApiResponse<Topup>`.
|
||||||
|
- **GET `/api/billing/topup/{id}`** `getTopup()` → `Topup`.
|
||||||
|
- **PATCH `…/topup/{id}/cancel/`** `cancelTopup()` → `Topup`.
|
||||||
|
- **PUT `…/topup/{id}/verify/`** `verifyTopupPayment()` → `{ status, message, transaction? { ref, sourceBank, trxDate } }`.
|
||||||
|
- **POST `/api/billing/admin-topup/`** `adminUserTopup()` — Body `{ amount, user_id, description }`.
|
||||||
|
- **GET `/api/billing/wallet-transactions/`** `getWaleltTransactions()` → `ApiResponse<WalletTransaction>` (`transaction_type: "DEBIT" | "TOPUP"`).
|
||||||
|
|
||||||
|
## Devices
|
||||||
|
|
||||||
|
`queries/devices.ts` (`checkSession()` for token).
|
||||||
|
|
||||||
|
- **GET `/api/devices/`** `getDevices()` — params `name, offset, limit, page, sortBy, status`, `all_devices=true` for admin. → `ApiResponse<Device>`.
|
||||||
|
- **GET `/api/devices/{id}/`** `getDevice()` → `Device`.
|
||||||
|
- **POST `/api/devices/`** `addDeviceAction()` — Body `{ name, mac, registered: true }` → `Device`.
|
||||||
|
- **PUT `/api/devices/{id}/block/`** `blockDeviceAction()` — Body `{ blocked, reason_for_blocking, blocked_by: "ADMIN" | "PARENT" }`. Parents forced to `PARENT`. → `Device`.
|
||||||
|
|
||||||
|
## Users / profile / geo
|
||||||
|
|
||||||
|
`queries/users.ts`, `queries/islands.ts`, `actions/user-actions.ts`.
|
||||||
|
|
||||||
|
- **GET `/api/auth/atolls/`** `getAtolls()` — atoll+island dropdowns on signup. → `Atoll[]` (each with nested `islands`).
|
||||||
|
- **GET `/api/auth/users/`** `getUsers()` — admin user list. → `ApiResponse<UserProfile>`.
|
||||||
|
- **GET `/api/auth/users/{id}/`** `getProfileById()` — profile & admin user pages. → `UserProfile`.
|
||||||
|
- **GET `/api/auth/profile/`** `getProfile()` — own profile (agreements, payment detail). → `User`.
|
||||||
|
- **PUT `/api/auth/users/{id}/verify/`** `verifyUser()` — → `{ ok, mismatch_fields, … }`; surfaces field mismatches.
|
||||||
|
- **DELETE `/api/auth/users/{id}/reject/`** `rejectUser()` — Body `{ rejection_details }`; `204` → revalidate/redirect.
|
||||||
|
- **PUT `/api/auth/users/{id}/update/`** `updateUser()` — Body = non-empty form fields. → `User` or per-field errors.
|
||||||
|
- **PUT `/api/auth/users/{id}/agreement/`** `updateUserAgreement()` — multipart FormData (PDF). → `{ agreement }`.
|
||||||
|
|
||||||
|
## Third-party
|
||||||
|
|
||||||
|
- **GET `{PERSON_VERIFY_BASE_URL}/api/person/{idCard}`** `getNationalPerson()` (`lib/person.ts`) — **LIVE**. National identity lookup to cross-check user data during admin verification. ISR `revalidate: 60`. → `TNationalPerson { nic, name(_en), dob, gender, house_name(_en), island_name(_en), atoll(_en), constituency, … }`. *Memory note: planned to route through the backend instead of calling directly.*
|
||||||
|
- **Omada** (`actions/omada-actions.ts`) — group profiles / add-to-group / block — **DEAD**, none imported by UI. Device blocking now uses backend `/api/devices/{id}/block/`. Consistent with the Omada→RADIUS migration.
|
||||||
|
- **Invoice Ninja** (`actions/ninja/client.ts`) — hardcoded staging URL, create client — **DEAD**.
|
||||||
|
|
||||||
|
## Frontend's own API routes (`app/api/`) — not backend
|
||||||
|
|
||||||
|
- **`app/api/auth/[...nextauth]/route.ts`** — NextAuth handler. **To be removed** in the static migration.
|
||||||
|
- **`app/api/check-devices/route.ts`** — stub returning `{ message: "Request received" }`; no backend call. Dead/placeholder.
|
||||||
|
|
||||||
|
## Response envelope
|
||||||
|
|
||||||
|
List endpoints return `ApiResponse<T> = { meta, links, data: T[] }` (pagination). Errors surface as `{ message }` / `{ detail }` (see `handleApiResponse` in `utils/tryCatch.ts`, which is hardened against non-JSON error bodies).
|
||||||
|
|
||||||
|
## Dead code to drop during the port
|
||||||
|
|
||||||
|
`/auth/login/` (#8), `/islands/`, `/inventory/`, all Omada, Invoice Ninja, `backendMobileLogin`, `app/api/check-devices`, and the two axios helpers `utils/axios-client.ts` / `utils/axiosInstance.ts` (only used by dead calls).
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## What the app is
|
||||||
|
|
||||||
|
SAR Link Portal — a customer + admin portal for an ISP. Users register (verified against the national identity registry), add network devices, and pay for device subscriptions either by bank transfer (MIB) or from a prepaid wallet they top up. Admins verify users, manage devices/payments/topups, and credit wallets.
|
||||||
|
|
||||||
|
## Stack (current)
|
||||||
|
|
||||||
|
- **Next.js 15** (App Router, React 19), TypeScript.
|
||||||
|
- **Tailwind CSS v4** (CSS-first, no config file) + **shadcn/ui** (`new-york`, neutral) + Radix + **lucide** icons.
|
||||||
|
- **Jotai** (global UI state) + **React Query** (mounted, not yet used).
|
||||||
|
- **react-hook-form** + **zod** (forms/validation), **nuqs** (URL query state), **sonner** (toasts), **next-themes**, **motion** (animation).
|
||||||
|
- **Auth:** mid-migration — old **next-auth** (JWT session) → new **localStorage token** (`lib/auth-store.ts`) + axios **api-client** (`lib/api-client.ts`).
|
||||||
|
|
||||||
|
## Folder map
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
(auth)/auth/{signin,signup,verify-otp,verify-otp-registration}/ # public auth pages
|
||||||
|
(dashboard)/{devices,payments,top-ups,wallet,users,...}/ # authed app (user + admin)
|
||||||
|
api/{auth/[...nextauth],check-devices}/ # frontend API routes (being removed)
|
||||||
|
layout.tsx page.tsx globals.css auth.ts
|
||||||
|
actions/ # server actions: auth-actions, payment, user-actions, (omada, ninja = dead)
|
||||||
|
queries/ # server data fetchers: authentication, devices, islands, users, wallet
|
||||||
|
components/ # feature components + components/ui (shadcn primitives)
|
||||||
|
lib/ # auth-store, api-client, atoms, backend-types, schemas, person, utils
|
||||||
|
utils/ # tryCatch, axios-client (dead), axiosInstance (dead)
|
||||||
|
providers/ # QueryProvider, theme, (AuthProvider = next-auth, being removed)
|
||||||
|
hooks/ middleware.ts(removed)
|
||||||
|
docs/ # this documentation
|
||||||
|
deploy/ # (superseded — real prod is .build/prod/, see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current architecture (BFF — what's being replaced)
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──(RSC + Server Actions)──▶ Next.js server ──(fetch + Token)──▶ Django API
|
||||||
|
```
|
||||||
|
|
||||||
|
The Next.js server sits between the browser and Django:
|
||||||
|
- Server components + server actions read the token via `getServerSession` and call Django server-side over `SARLINK_API_BASE_URL`.
|
||||||
|
- Public origin `/api/*` = Next.js's own routes (NextAuth); Django's `/api` is reached only internally.
|
||||||
|
- Deployed as a Node container behind nginx (`.build/prod/`).
|
||||||
|
|
||||||
|
## Target architecture (static + nginx, direct-to-API)
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──(static HTML/JS from nginx)
|
||||||
|
├─ API_URL calls (/api, /callback, /auth/*) ─▶ nginx ─▶ Django
|
||||||
|
└─ everything else ─▶ nginx serves the static build
|
||||||
|
```
|
||||||
|
|
||||||
|
Goals set by the project owner:
|
||||||
|
- **No Node/Bun in production** — ship a static build served by nginx.
|
||||||
|
- **Configurable API base:** `API_URL=http://localhost:8000` in dev, `API_URL=https://portal.sarlink.net/api` (nginx → backend) in prod.
|
||||||
|
- Browser calls the Django API **directly** (same-origin in prod via nginx; cross-origin in dev with CORS).
|
||||||
|
|
||||||
|
Two ways to get there (under discussion):
|
||||||
|
1. **Next.js static export** (`output: "export"`) — keeps Next; fights the framework (no server actions, `searchParams` needs Suspense, dynamic routes need query-params, middleware gone). Foundation already built: `lib/auth-store.ts`, `lib/api-client.ts`, `components/auth/route-guard.tsx`, config flip, dev `rewrites()` proxy. See `../STATIC_MIGRATION_PLAN.md`.
|
||||||
|
2. **Vite + React SPA (recommended)** — natural fit for a static client app with a configurable `API_URL` (`import.meta.env.VITE_API_URL`). A **port, not a rewrite**: all components, shadcn, react-query, jotai, zod transfer 1:1; only the shell (routing, `next/link`, `next/font`, `next/image`, layouts) changes. TanStack Router or React Router for routing.
|
||||||
|
|
||||||
|
Either way the **auth/data plumbing already written is reusable**: token in localStorage, `Authorization: Token` interceptor, client route guard, React Query for reads/mutations.
|
||||||
|
|
||||||
|
## Auth model (target)
|
||||||
|
|
||||||
|
1. Login: `POST {API}/callback/auth/ { token: pin }` → `{ token, user, expiry }`.
|
||||||
|
2. Store token + user in localStorage (`setAuth`).
|
||||||
|
3. Attach `Authorization: Token <token>` on every request (axios interceptor).
|
||||||
|
4. `RouteGuard` gates authed pages; `401` → `clearAuth()` + redirect to signin.
|
||||||
|
5. Admin gating from `user.is_admin` / `user.user_permissions`.
|
||||||
|
|
||||||
|
## Production deployment (where nginx lives)
|
||||||
|
|
||||||
|
The real prod stack is **`.build/prod/`** (repo root), not `frontend/deploy/`:
|
||||||
|
- `compose.yml` — postgres + backend (Django/gunicorn) + frontend + **nginx** (single entrypoint, `:8080→80`).
|
||||||
|
- `nginx.conf` — reverse proxy. **Currently built for the BFF model** (`/` → Next Node server; `/api` NOT proxied to Django). For the static migration this must change to: serve the static build + proxy `/api`, `/callback`, `/auth/{login,logout,mobile}` to Django; the separate Node `frontend` service goes away.
|
||||||
|
- `frontend.Dockerfile` — currently `output:"standalone"` + `node server.js`; becomes a static build folded into the nginx image.
|
||||||
|
|
||||||
|
See [api-endpoints.md](./api-endpoints.md) for exact paths and the `/auth/*` frontend-vs-backend collision that the nginx config must handle.
|
||||||
|
|
||||||
|
## Known issues / cleanup
|
||||||
|
|
||||||
|
- **Mid-migration auth:** dashboard pages/actions/queries still use `getServerSession`; they must move to the client token store (or be removed in the SPA port).
|
||||||
|
- **Stale wallet balance:** header balance comes from the login `userAtom` snapshot; not refreshed after topups/payments until re-login.
|
||||||
|
- **Dead code:** Omada, Invoice Ninja, `/auth/login/`, `/islands/`, `/inventory/`, `backendMobileLogin`, `app/api/check-devices`, `utils/axios-client.ts`, `utils/axiosInstance.ts`.
|
||||||
|
- **person-verify** is called directly from the frontend; planned to route through the backend.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Components
|
||||||
|
|
||||||
|
Catalog of `components/` by feature area. `components/ui/*` are shadcn/ui + Radix primitives (listed last). "Server" = React Server Component today; most will become client components in the static build.
|
||||||
|
|
||||||
|
## Auth (`components/auth/`)
|
||||||
|
|
||||||
|
- **`login-form.tsx`** (client) — phone-number login entry. `PhoneInput` + Login button; drives the `signin` flow.
|
||||||
|
- **`signup-form.tsx`** (client) — full registration form: name, ID card, atoll/island (atolls fetched, islands derived from atoll), address, DOB, account no, phone, terms/policy. Two-column (branding + form). Field-level validation.
|
||||||
|
- **`verify-otp-form.tsx`** (client) — login OTP entry. 6-digit input → `POST /callback/auth/` → `setAuth()` (localStorage) → redirect. Toasts on error.
|
||||||
|
- **`verify-registration-otp-form.tsx`** (client) — registration OTP entry (server action).
|
||||||
|
- **`route-guard.tsx`** (client) — auth gate for the dashboard. Renders nothing until `isAuthenticated()` passes; else redirects to signin with `callbackUrl`. Replaces old next-auth middleware.
|
||||||
|
- **`account-popver.tsx`** (client) — header account menu. Reads `userAtom`; shows name/ID/phone; Logout (backend logout + `clearAuth()`) and View Profile.
|
||||||
|
- **`application-layout.tsx`** (client) — the authenticated app shell: `SidebarProvider` + `AppSidebar` + sticky header (wallet, theme toggle, account popover) + `WelcomeBanner` + `DeviceCartDrawer` + main content in `NuqsAdapter`.
|
||||||
|
|
||||||
|
## Devices
|
||||||
|
|
||||||
|
- **`devices-table.tsx`** (server) — desktop table + mobile cards of the user's devices; pagination; respects `parentalControl` and admin flags; fetches `getDevices()`.
|
||||||
|
- **`device-card.tsx`** (client) — mobile device card: name, MAC/vendor badges, active/inactive, expiry, pending-payment indicator, blocked reason. Toggles cart selection.
|
||||||
|
- **`add-devices-to-cart-button.tsx`** (client) — styled checkbox toggling a device in `deviceCartAtom`; disabled if active/blocked/pending.
|
||||||
|
- **`device-cart.tsx`** (client) — floating sticky "Pay N device(s)" / Cancel banner; hidden when empty or on payment pages; routes to `/devices-to-pay`.
|
||||||
|
- **`devices-to-pay.tsx`** (client) — pick number of months → `createPayment` → redirect to payment detail. Also renders bank details + pay-with-wallet / I-have-paid on the payment page.
|
||||||
|
- **`devices-for-payment.tsx`** (client) — confirm selected devices + months, submit payment.
|
||||||
|
- **`devices/device-filter.tsx`** (client) — advanced device filter drawer (name/MAC/vendor) with active-filter chips via `nuqs`.
|
||||||
|
- **`how-to-get-mac.tsx`** — help accordion: how to find a MAC address per device type; support phone.
|
||||||
|
- **`block-device-dialog.tsx`** (client) — block/unblock. Parental mode = simple block/unblock; admin mode = dialog with reason. Calls `blockDeviceAction`.
|
||||||
|
- **`device-table-skeleton.tsx`** — loading skeleton.
|
||||||
|
|
||||||
|
## Payments / billing
|
||||||
|
|
||||||
|
- **`payments-table.tsx`** (server) — user subscriptions; desktop table + `MobilePaymentDetails`; status/row color coding; device list per row; `getPayments()`.
|
||||||
|
- **`topups-table.tsx`** (server) — user top-ups; table + `MobileTopupDetails`.
|
||||||
|
- **`topup-to-pay.tsx`** (client) — topup detail + bank info + "I have paid" → `verifyTopupPayment`.
|
||||||
|
- **`account-information.tsx`** (client) — bank account name/number with copy-to-clipboard.
|
||||||
|
- **`billing/cancel-payment-button.tsx`** (client) — cancel unpaid payment → `cancelPayment`.
|
||||||
|
- **`billing/cancel-topup-button.tsx`** (client) — cancel unpaid topup → `cancelTopup`.
|
||||||
|
- **`billing/expiry-time-countdown.tsx`** (client) — 1s countdown + progress bar for unpaid items; redirects on expiry.
|
||||||
|
|
||||||
|
## Wallet
|
||||||
|
|
||||||
|
- **`wallet.tsx`** (client) — header wallet-balance button; opens top-up drawer (`NumberInput`, max 5000) → `createTopup` → redirect to topup detail. Hidden on payment pages.
|
||||||
|
- **`wallet-transactions-table.tsx`** (server) — transaction history; Total Debit/Credit summary boxes; table + `MobileTransactionDetails`; links to related payment/topup.
|
||||||
|
|
||||||
|
## Admin (`components/admin/`)
|
||||||
|
|
||||||
|
- **`admin-devices-table.tsx`** (server) — all devices; user column; block/unblock; `getDevices(..., true)`.
|
||||||
|
- **`admin-topup-form.tsx`** (client) — manual wallet credit dialog → `adminUserTopup`.
|
||||||
|
- **`admin-topup-table.tsx`** (server) — all top-ups.
|
||||||
|
- **`user-payments-table.tsx`** (server) — all payments; status/method/MIB ref columns.
|
||||||
|
|
||||||
|
## User management (`components/user/`)
|
||||||
|
|
||||||
|
- **`add-device-dialog.tsx`** (client) — add device (name + MAC) with MAC help accordion → `addDeviceAction`.
|
||||||
|
- **`user-agreement-form.tsx`** (client) — upload/replace agreement PDF → `updateUserAgreement`.
|
||||||
|
- **`user-update-form.tsx`** (client) — edit user info (ID card, name, address, DOB, mobile) → `updateUser`.
|
||||||
|
- **`user-verify-dialog.tsx`** (client) — admin verify; warns on `mismatch_fields` → `verifyUser`.
|
||||||
|
- **`user-reject-dialog.tsx`** (client) — admin reject with reason → `rejectUser`.
|
||||||
|
- **`user-table.tsx`** (server) — all users; verified/unverified badges; Details link.
|
||||||
|
|
||||||
|
## Layout / navigation
|
||||||
|
|
||||||
|
- **`ui/app-sidebar.tsx`** (client) — the sidebar (see [navigation.md](./navigation.md)).
|
||||||
|
- **`welcome-banner.tsx`** (client) — animated greeting, auto-hides after 4s (Framer Motion).
|
||||||
|
- **`theme-toggle.tsx`** (client) — Light/Dark/System (`next-themes`).
|
||||||
|
|
||||||
|
## Shared / utility
|
||||||
|
|
||||||
|
- **`pagination.tsx`** (client) — page controls preserving query params; hidden if ≤1 page.
|
||||||
|
- **`clickable-row.tsx`** (client) — table row with cart toggle + status.
|
||||||
|
- **`search.tsx`** (client) — debounced search → URL `query` param.
|
||||||
|
- **`filter.tsx`** (client) — status select → URL param.
|
||||||
|
- **`generic-filter.tsx`** (client) — reusable filter drawer + chips.
|
||||||
|
- **`number-input.tsx`** — React Aria numeric input with +/- and max.
|
||||||
|
- **`agreement-card.tsx`** — agreement display + View button.
|
||||||
|
- **`price-calculator.tsx`** (client) — pricing formula tool (Jotai-backed inputs).
|
||||||
|
- **`full-page-loader.tsx`** — full-screen spinner.
|
||||||
|
- **`client-error-message.tsx`** — permission/error message with support contact.
|
||||||
|
- **`input-read-only.tsx`**, **`ui/floating-label.tsx`** — read-only/labeled inputs.
|
||||||
|
|
||||||
|
## UI primitives (`components/ui/`)
|
||||||
|
|
||||||
|
shadcn/ui (`new-york`) + Radix. No product logic. Grouped:
|
||||||
|
- **Inputs:** input, textarea, label, form, floating-label, phone-input, input-otp, number-field, select, checkbox, radio-group, switch, toggle(+group), slider, dual-range-slider, calendar, datepicker.
|
||||||
|
- **Layout:** card, separator, scroll-area, sidebar, resizable, aspect-ratio, collapsible.
|
||||||
|
- **Overlays:** dialog, drawer, sheet, alert-dialog, popover, hover-card, tooltip.
|
||||||
|
- **Data display:** table, badge, progress, accordion, tabs, breadcrumb, pagination, avatar, carousel, text-shimmer, skeleton.
|
||||||
|
- **Menus:** dropdown-menu, context-menu, command, navigation-menu, menubar.
|
||||||
|
- **Feedback:** button, alert, sonner (toasts), search-form.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Forms:** server-action forms use `useActionState` with a `{ message, success, fieldErrors }` shape; client validation via `react-hook-form` + `zod`.
|
||||||
|
- **Data:** reads currently go through server components/queries; mutations through server actions. React Query provider is mounted but **no `useQuery`/`useMutation` yet** — the port will move reads/mutations onto React Query + the client `api-client`.
|
||||||
|
- **State:** global UI state via Jotai atoms (see [user-flows-and-state.md](./user-flows-and-state.md)).
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Everything needed to reproduce the visual look of the SAR Link Portal. The UI is built on **shadcn/ui** (style: `new-york`, base color: `neutral`, icon set: `lucide`) over **Tailwind CSS v4** (CSS-first config — there is no `tailwind.config.ts`; theming lives in `app/globals.css` via `@theme`).
|
||||||
|
|
||||||
|
## Brand color
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-sarLinkOrange` / `text-sarLinkOrange` | `#f49b5b` | Primary brand accent — page headings, logo text, highlights, top loader |
|
||||||
|
| Pattern orange (dark) | `#e06f10` | Diagonal SVG background pattern (opacity 0.35) |
|
||||||
|
| Pattern orange (light) | `#f49b5b` | `.title-bg` SVG background pattern (opacity 0.1) |
|
||||||
|
|
||||||
|
The brand orange is used mainly as an **accent on top of a neutral (grayscale) shadcn palette** — it is not the shadcn `--primary`. Primary/secondary/muted etc. remain the default neutral shadcn tokens.
|
||||||
|
|
||||||
|
## Theme tokens
|
||||||
|
|
||||||
|
Colors use the **oklch** color space, neutral base. Defined as CSS variables in `app/globals.css` under `:root` (light) and `.dark` (dark). Standard shadcn token set:
|
||||||
|
|
||||||
|
`--background --foreground --card --card-foreground --popover --popover-foreground --primary --primary-foreground --secondary --secondary-foreground --muted --muted-foreground --accent --accent-foreground --destructive --border --input --ring` plus a sidebar group `--sidebar --sidebar-foreground --sidebar-primary --sidebar-accent --sidebar-border --sidebar-ring`.
|
||||||
|
|
||||||
|
Key values:
|
||||||
|
- **Light:** `--background: oklch(1 0 0)` (white), `--foreground: oklch(0.145 0 0)` (near-black), `--primary: oklch(0.205 0 0)`.
|
||||||
|
- **Dark:** `--background: oklch(0.145 0 0)`, `--foreground: oklch(0.985 0 0)`, `--primary: oklch(0.922 0 0)`.
|
||||||
|
- **Radius:** `--radius: 0.625rem`, with `sm/md/lg/xl` derived (`calc(var(--radius) ± n)`).
|
||||||
|
- App background (body): `bg-gray-100` light / `bg-black` dark (set in root layout).
|
||||||
|
|
||||||
|
Dark mode is class-based (`@custom-variant dark (&:is(.dark *))`) via `next-themes` (`attribute="class"`, default `system`).
|
||||||
|
|
||||||
|
## Custom utility classes (in `app/globals.css`)
|
||||||
|
|
||||||
|
- **`.title-bg`** — subtle diagonal SVG pattern in brand orange at 0.1 opacity. Used behind page-heading blocks and auth/login cards to give the faint textured background.
|
||||||
|
- A second diagonal-line SVG pattern using `#e06f10` at 0.35 opacity.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Two Google fonts loaded in `app/layout.tsx` as CSS variables:
|
||||||
|
|
||||||
|
| Font | Variable | Weights | Use |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Barlow** | `--font-barlow` (also the `font-sans` body font) | 100,300,400,500,600,700,800,900 | Body text, UI, everything by default |
|
||||||
|
| **Bokor** | `--font-bokor` | 400 | Display / decorative headings (brand) |
|
||||||
|
| mono | `--font-mono` | — | Monospace bits (e.g. "Profile Status" label) |
|
||||||
|
|
||||||
|
Body element applies `${barlow.variable} ${bokor.variable} antialiased font-sans`.
|
||||||
|
|
||||||
|
## Tailwind plugins in use
|
||||||
|
|
||||||
|
`tailwindcss-animate`, `@pyncz/tailwind-mask-image`, `tailwindcss-motion`. Animations also via `motion` (Framer Motion) — used by the welcome banner and some transitions. `TextShimmer` component for shimmer loading states.
|
||||||
|
|
||||||
|
## Status color conventions
|
||||||
|
|
||||||
|
These recur across badges and table rows (payments, topups, devices, users). Reproduce consistently:
|
||||||
|
|
||||||
|
| State | Color | Typical classes |
|
||||||
|
|---|---|---|
|
||||||
|
| Paid / Verified / Credit / success | green / lime | `bg-green-500 text-white`, `bg-lime-*`, green row tint |
|
||||||
|
| Pending / awaiting | yellow | `bg-yellow-500 text-white`, yellow row tint |
|
||||||
|
| Failed / Cancelled / Rejected / Debit | red | `bg-red-500 text-white`, `destructive` buttons |
|
||||||
|
| Expired | gray | gray row tint / muted |
|
||||||
|
| Unknown | yellow | `bg-yellow-500` |
|
||||||
|
| Active device (until date) | green accent | brand/green text |
|
||||||
|
| Inactive / blocked | red / muted | red text, block dialog |
|
||||||
|
|
||||||
|
## Recurring layout patterns
|
||||||
|
|
||||||
|
- **Page heading block** — a flex row with a dashed border, `title-bg` background, rounded, `text-sarLinkOrange text-2xl` heading on the left, optional status/action on the right. (See `profile`, most list pages.)
|
||||||
|
- **Dual list layout** — every list screen renders an HTML **`<table>` on desktop** and a stack of **cards on mobile** (`MobilePaymentDetails`, `MobileTopupDetails`, `MobileTransactionDetails`, `device-card`), showing the same data. Footer shows `Total N item(s).` and pagination.
|
||||||
|
- **Read-only field grid** — labeled read-only values in a responsive grid (`grid-cols-1 sm:grid-cols-2 md:grid-cols-3`), used by profile & user details.
|
||||||
|
- **Drawers** (shadcn `drawer`/`vaul`) — device cart, wallet top-up, and filter panels open as bottom/side drawers.
|
||||||
|
- **Filter drawer + active-filter chips** — filters applied to URL query params (via `nuqs`), shown as dismissible badges.
|
||||||
|
- **Skeletons** — `DevicesTableSkeleton` and per-route `loading.tsx` files provide loading UI; `FullPageLoader` for full-screen spins.
|
||||||
|
- **Countdown** — `ExpiryTimeCountdown` shows `Time left: …` with a progress bar for unpaid payments/topups, ticking every second.
|
||||||
|
|
||||||
|
## App shell (authenticated)
|
||||||
|
|
||||||
|
Rendered by `components/auth/application-layout.tsx`:
|
||||||
|
- **Sidebar** (`AppSidebar`) on the left inside `SidebarProvider` — see [navigation.md](./navigation.md).
|
||||||
|
- **Sticky header** (`h-16`, `border-b`, `sticky top-0`, `z-10`): left = sidebar trigger + separator; right = **Wallet balance** button, **theme toggle**, **account popover**.
|
||||||
|
- **WelcomeBanner** — animated "Welcome, {first} {last}" that auto-hides after 4s.
|
||||||
|
- **DeviceCartDrawer** — floating cart (hidden on payment pages).
|
||||||
|
- **Main content** — `p-4`, rounded, `bg-background`, wrapped in `NuqsAdapter` for URL state.
|
||||||
|
|
||||||
|
## Auth-page shell
|
||||||
|
|
||||||
|
`app/(auth)/auth/layout.tsx`: centered full-screen container (`bg-gray-100` light / `bg-black` dark) holding a single card. Login card uses `title-bg` and a `border-2 border-sarLinkOrange/50 rounded-lg shadow`.
|
||||||
|
|
||||||
|
## Global chrome
|
||||||
|
|
||||||
|
- **NextTopLoader** — top progress bar, color `#f49d1b` (orange), no spinner.
|
||||||
|
- **Toaster** (`sonner`, `richColors`) — all success/error toasts.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Navigation
|
||||||
|
|
||||||
|
The sidebar (`components/ui/app-sidebar.tsx`) is the primary navigation. It is a shadcn `Sidebar` with a header (brand) and two collapsible category groups. Menu items are **data-driven** from a `categories` array and **filtered by the user's permissions / admin flag**.
|
||||||
|
|
||||||
|
## Sidebar header
|
||||||
|
|
||||||
|
Brand block at the top: a bordered, centered, uppercase title with `title-bg` background (the "SAR LINK" branding).
|
||||||
|
|
||||||
|
## Menu tree
|
||||||
|
|
||||||
|
### Group: `MENU` (all users)
|
||||||
|
|
||||||
|
| Label | Route | Icon (lucide) | Permission (`perm_identifier`) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Devices | `/devices?page=1` | `Smartphone` | `device` |
|
||||||
|
| Parental Control | `/parental-control?page=1` | `CreditCard` | `device` |
|
||||||
|
| Subscriptions | `/payments?page=1` | `CreditCard` | `payment` |
|
||||||
|
| Top Ups | `/top-ups?page=1` | `BadgePlus` | `topup` |
|
||||||
|
| Transaction History | `/wallet` | `Wallet2Icon` | `wallet transaction` |
|
||||||
|
| Agreements | `/agreements` | `Handshake` | `device` |
|
||||||
|
|
||||||
|
### Group: `ADMIN CONTROL` (admin only)
|
||||||
|
|
||||||
|
| Label | Route | Icon (lucide) | Permission (`perm_identifier`) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Users | `/users` | `UsersRound` | `device` |
|
||||||
|
| User Devices | `/user-devices` | `MonitorSpeaker` | `device` |
|
||||||
|
| User Payments | `/user-payments` | `Coins` | `payment` |
|
||||||
|
| User Topups | `/user-topups` | `Coins` | `topup` |
|
||||||
|
| Price Calculator | `/price-calculator` | `Calculator` | `device` |
|
||||||
|
|
||||||
|
> Note: the `Price Calculator` sits under `ADMIN CONTROL` in the source array, so it is only shown to admins even though the page itself is generic.
|
||||||
|
|
||||||
|
## Visibility logic
|
||||||
|
|
||||||
|
Computed in `app-sidebar.tsx` from the current user (now read from `userAtom`; previously `getServerSession`):
|
||||||
|
|
||||||
|
1. **If `user.is_admin`** → show **all** categories and items.
|
||||||
|
2. **Else** →
|
||||||
|
- Drop the entire `ADMIN CONTROL` category.
|
||||||
|
- For remaining items, keep an item only if the user has a matching permission: the item's `perm_identifier` is compared against the model name parsed from each `user.user_permissions[].name` (permission name split on spaces, model = parts from index 2 onward).
|
||||||
|
- Drop any category left with zero visible children.
|
||||||
|
|
||||||
|
So a non-admin sees a subset of the `MENU` group based on granted permissions; the whole admin group is hidden.
|
||||||
|
|
||||||
|
## Other navigation entry points
|
||||||
|
|
||||||
|
- **Header account popover** (`account-popver.tsx`): links to **View Profile** (`/profile`) and **Logout**.
|
||||||
|
- **Header wallet button** (`wallet.tsx`): opens the top-up drawer (not a route).
|
||||||
|
- **Device cart** (`device-cart.tsx`): "Pay" routes to `/devices-to-pay`.
|
||||||
|
- **Root `/`**: redirects to `/devices` (authed) or `/auth/signin` (not authed).
|
||||||
|
- **Admin list rows**: "Details" buttons link into `/users/[userId]/details`, `/devices/[deviceId]`, `/payments/[paymentId]`, `/top-ups/[topupId]`.
|
||||||
|
- **Table pagination & filters**: mutate URL query params (`?page=`, `?query=`, `?status=`, `?sortBy=` …) via `nuqs`.
|
||||||
|
|
||||||
|
See [routes.md](./routes.md) for the full route list and [navigation gating] mirrors the admin route guards documented there.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Routes & Pages
|
||||||
|
|
||||||
|
Next.js App Router. Route groups `(auth)` and `(dashboard)` do **not** appear in the URL. Dynamic segments in `[brackets]`.
|
||||||
|
|
||||||
|
## Route table
|
||||||
|
|
||||||
|
| Folder | URL | Page | Audience |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | `/` | Home (redirect guard) | any |
|
||||||
|
| `(auth)/auth/signin` | `/auth/signin` | Sign In (phone) | public |
|
||||||
|
| `(auth)/auth/signup` | `/auth/signup` | Sign Up | public |
|
||||||
|
| `(auth)/auth/verify-otp` | `/auth/verify-otp` | Verify OTP (login) | public |
|
||||||
|
| `(auth)/auth/verify-otp-registration` | `/auth/verify-otp-registration` | Verify OTP (registration) | public |
|
||||||
|
| `(dashboard)/devices` | `/devices` | My Devices | user |
|
||||||
|
| `(dashboard)/devices/[deviceId]` | `/devices/{id}` | Device Details | user |
|
||||||
|
| `(dashboard)/devices-to-pay` | `/devices-to-pay` | Devices to Pay | user |
|
||||||
|
| `(dashboard)/parental-control` | `/parental-control` | Parental Control | user |
|
||||||
|
| `(dashboard)/payments` | `/payments` | My Subscriptions | user |
|
||||||
|
| `(dashboard)/payments/[paymentId]` | `/payments/{id}` | Payment Details | user |
|
||||||
|
| `(dashboard)/top-ups` | `/top-ups` | My Topups | user |
|
||||||
|
| `(dashboard)/top-ups/[topupId]` | `/top-ups/{id}` | Topup Details | user |
|
||||||
|
| `(dashboard)/wallet` | `/wallet` | Transaction History | user |
|
||||||
|
| `(dashboard)/agreements` | `/agreements` | Agreements | user |
|
||||||
|
| `(dashboard)/price-calculator` | `/price-calculator` | Price Calculator | user (admin-nav) |
|
||||||
|
| `(dashboard)/profile` | `/profile` | Profile | user |
|
||||||
|
| `(dashboard)/user-devices` | `/user-devices` | All User Devices | admin |
|
||||||
|
| `(dashboard)/user-payments` | `/user-payments` | All User Payments | admin |
|
||||||
|
| `(dashboard)/user-topups` | `/user-topups` | All User Topups | admin |
|
||||||
|
| `(dashboard)/users` | `/users` | Users Management | admin |
|
||||||
|
| `(dashboard)/users/[userId]/details` | `/users/{id}/details` | User Details | admin |
|
||||||
|
| `(dashboard)/users/[userId]/update` | `/users/{id}/update` | User Update / Verify | admin |
|
||||||
|
| `(dashboard)/users/[userId]/agreement` | `/users/{id}/agreement` | User Agreement Upload | admin |
|
||||||
|
|
||||||
|
## Layouts & special files
|
||||||
|
|
||||||
|
- **`app/layout.tsx`** (root) — html/body, fonts, providers: Jotai `Provider`, `NextTopLoader`, `Toaster`, `ThemeProvider`, `QueryProvider`. Metadata: "SAR Link Portal".
|
||||||
|
- **`app/(auth)/auth/layout.tsx`** — centered full-screen card layout for all auth pages.
|
||||||
|
- **`app/(dashboard)/layout.tsx`** — `RouteGuard` → `ApplicationLayout` (sidebar+header shell) → `QueryProvider` → children.
|
||||||
|
- **`app/(dashboard)/error.tsx`** — dashboard error boundary ("Something went wrong!" + Try again).
|
||||||
|
- **`loading.tsx`** files — `devices`, `devices/[deviceId]`, `devices-to-pay`, `payments`, `payments/[paymentId]`, `parental-control` (skeletons / loaders).
|
||||||
|
|
||||||
|
## Auth & onboarding
|
||||||
|
|
||||||
|
- **`/auth/signin`** — `LoginForm`. Enter phone number. `signin()` action checks the number: unknown → redirect `/auth/signup?phone_number=`; known+unverified → "pending verification" message; known+verified → sends OTP (`/auth/mobile/`) → `/auth/verify-otp?phone_number=`.
|
||||||
|
- **`/auth/signup`** — `SignUpForm`. Full registration (name, ID card, atoll/island, address, DOB, account no, phone, terms/policy). Requires `?phone_number=`. On submit → register → `/auth/verify-otp-registration`.
|
||||||
|
- **`/auth/verify-otp`** — `VerifyOTPForm` (client, `useSearchParams` in Suspense). Enter 6-digit PIN → `POST /callback/auth/` → stores token+user → `/devices`. Redirects to `/auth/signin` if no `phone_number`.
|
||||||
|
- **`/auth/verify-otp-registration`** — `VerifyRegistrationOTPForm`. Verifies registration OTP; validates the temp record. On success → `/auth/signin` (account stays pending admin verification; no auto-login).
|
||||||
|
|
||||||
|
## Dashboard — user
|
||||||
|
|
||||||
|
All wrapped by `RouteGuard` (redirects to `/auth/signin` if not authenticated).
|
||||||
|
|
||||||
|
- **`/devices`** — `DevicesTable` + `DynamicFilter` + add-device dialog. List of the user's devices; filter by name/MAC/vendor; paginated.
|
||||||
|
- **`/devices/{id}`** — single device: name, MAC, status badge, expiry.
|
||||||
|
- **`/devices-to-pay`** — `DevicesForPayment`: devices selected in the cart, choose number of months, create a payment.
|
||||||
|
- **`/parental-control`** — `DevicesTable` with `parentalControl` mode, filtered to active devices with no pending payment; block/unblock devices.
|
||||||
|
- **`/payments`** — `PaymentsTable` + filters (status, method, months). The user's subscriptions.
|
||||||
|
- **`/payments/{id}`** — payment detail: status, expiry countdown (if pending), covered devices (`DevicesToPay`), cancel button, pay via wallet/transfer.
|
||||||
|
- **`/top-ups`** — `TopupsTable` + filters (status, expiry, amount). Wallet top-ups.
|
||||||
|
- **`/top-ups/{id}`** — topup detail: status, countdown, `TopupToPay` (bank details + "I have paid"), cancel.
|
||||||
|
- **`/wallet`** — `WalletTransactionsTable`: debit/credit history with totals.
|
||||||
|
- **`/agreements`** — `AgreementCard`: user's service agreement (fetched from profile), Print/View.
|
||||||
|
- **`/price-calculator`** — `PriceCalculator`: interactive pricing tool.
|
||||||
|
- **`/profile`** — read-only profile fields + verification status badge. (Recently restyled to non-editable display.)
|
||||||
|
|
||||||
|
## Dashboard — admin
|
||||||
|
|
||||||
|
All additionally check `is_admin`; non-admins are redirected (to `/devices` or the user-equivalent page).
|
||||||
|
|
||||||
|
- **`/user-devices`** — `AdminDevicesTable`: all devices system-wide; filter incl. by device user; block/unblock with reason.
|
||||||
|
- **`/user-payments`** — `UsersPaymentsTable`: all payments; filters (user, MIB ref, amount, duration, status, method).
|
||||||
|
- **`/user-topups`** — `AdminTopupsTable`: all topups; filters (user, status, expiry, amount); admin manual top-up.
|
||||||
|
- **`/users`** — `UsersTable`: all users; filters (name, ID card, house, phone, verified status).
|
||||||
|
- **`/users/{id}/details`** — DB vs **National registry** comparison (via `getNationalPerson`), photo, verify/reject actions, links to update/agreement.
|
||||||
|
- **`/users/{id}/update`** — `UserUpdateForm`: edit/verify user info.
|
||||||
|
- **`/users/{id}/agreement`** — `UserAgreementForm`: upload/replace the user's agreement PDF.
|
||||||
|
|
||||||
|
## Guards summary
|
||||||
|
|
||||||
|
- Unauthenticated → `RouteGuard` / root redirect send to `/auth/signin`.
|
||||||
|
- Admin pages → `is_admin` check; redirect to the user equivalent.
|
||||||
|
- `401 UNAUTHORIZED` from the API → redirect `/auth/signin` (via api-client interceptor).
|
||||||
|
|
||||||
|
> Migration note: several dashboard pages still read auth server-side (`getServerSession`); see [architecture.md](./architecture.md) and [user-flows-and-state.md](./user-flows-and-state.md) for the in-progress move to the client token store.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# UI Copy Inventory
|
||||||
|
|
||||||
|
Every hardcoded user-facing string in `app/` and `components/` (excludes `components/ui/` primitives). Interpolated parts marked `{like_this}`. Includes messages that originate in `actions/`, `queries/`, `lib/schemas.ts` where they surface in the UI. This feeds i18n and the framework port.
|
||||||
|
|
||||||
|
## 1. Auth & onboarding
|
||||||
|
|
||||||
|
**login-form** — `Enter phone number` (placeholder), `Login` (button).
|
||||||
|
|
||||||
|
**verify-otp-form** — `Login OTP Sent to {phone_number}`, `Enter the OTP` (sr-only), `Enter OTP` (placeholder), `Login`, `Change` / `phone number` (link), toasts: `The token you entered isn't valid.`, `Unable to log in. Please try again or contact support.`
|
||||||
|
|
||||||
|
**verify-registration-otp-form** — `Account verification OTP sent to [{phone_number}]`, `Enter the OTP`, `Enter OTP`, `Request verification`, `Go to` / `login`.
|
||||||
|
|
||||||
|
**signup-form** — `SAR Link by OmegaTech Solutions`, `Register your account`, `Pay for your devices and track your bills.`; labels/placeholders: `Full Name`, `ID Card`, `Atoll`/`Select atoll`/`Atolls`, `Island`/`Select island`/`Islands`, `Address`, `Date of Birth`/`Date of birth`, `Account Number`/`Account no`, `Phone Number`/`Phone number`; `i accept` + `terms and conditions`, `i undertand` (sic) + `the privacy policy`; `Submit`; `Already have an account?` / `login`.
|
||||||
|
|
||||||
|
**auth-actions** (surfaced via forms/toasts) — `Please enter a phone number`, `Your account is on pending verification. Please wait for a response from admin or contact shihaam.` (hardcoded name), `Invalid form data`, `You must be at least 18 years old to register.`, `ID card already exists.`, `Phone number already exists.`, `User created successfully`.
|
||||||
|
|
||||||
|
**queries/authentication** — `OTP is required.`, `Your account has been successfully verified! You may login now.`, `Your account could not be verified. Please wait for you verification to be processed.` (sic).
|
||||||
|
|
||||||
|
## 2. Devices
|
||||||
|
|
||||||
|
**devices page** — `My Devices`; `Device Filter` / `Filter devices by name, MAC address, or vendor.`; `Device Name`/`Enter device name`, `MAC Address`/`Enter MAC address`, `Vendor`/`Enter vendor name`; table: `Device Name`, `Mac Address`, `Vendor`, `#`.
|
||||||
|
|
||||||
|
**device detail** — `Device active until {expiry_date}`, `ACTIVE`.
|
||||||
|
|
||||||
|
**user-devices page** — `User Devices`; filter `Device User`/`User name or id card`; `loading....`.
|
||||||
|
|
||||||
|
**device-card** — `Active until {expiry_date}`, `Device Inactive`, `Payment Pending`, `Blocked by admin` + `{reason}`.
|
||||||
|
|
||||||
|
**device-cart** — `Pay {count} device` / `Pay {count} devices`, `Cancel`.
|
||||||
|
|
||||||
|
**devices-table** — `No active devices` / `No devices.`; `Total {count} device(s).`.
|
||||||
|
|
||||||
|
**devices-to-pay** — `Devices to pay` / `Devices Paid`, `Please send the following amount to the payment address`, bank `Baraveli Dev` / `90101400028321000`, `Payment Verified`, `Processing payment...`, `Pay with wallet`, `I have paid`; rows: `Payment created`, `Total Devices`, `Duration`, `{count} Months`, `Total Due`.
|
||||||
|
|
||||||
|
**devices-for-payment** — `Set No of Months`, `Go to payment`.
|
||||||
|
|
||||||
|
**block-device-dialog** — `Unblock`/`Unblocking`, `Block`/`Blocking`/`Blocking...`, `Block 🚫` (title), `Please provide a reason for blocking this device`, `Reason for blocking`.
|
||||||
|
|
||||||
|
**how-to-get-mac** — `How do I find my MAC Address?` + description; per-device steps (iPhone/Redmi/Samsung/Windows/Other); support phone `9198026`.
|
||||||
|
|
||||||
|
**device-filter** — `Filter`, `Device Filters`, `Select your desired filters here`; placeholders `Device name ...`, `Device Mac address ...`, `Device vendor ...`; `Apply Filters`, `Clear Filters`, `Cancel`; chips `Device Name: {v}` etc., `Remove`.
|
||||||
|
|
||||||
|
**admin-devices-table** — `No devices.`; headers `Device Name`,`User`,`MAC Address`,`Vendor`,`#`; `Comment` (block reason); `Total {count} device(s).`.
|
||||||
|
|
||||||
|
**add-device-dialog** — `Add Device`, `New Device`, `To add a new device, enter the device name and mac address below. Click save when you are done.`, `Device Name`/`eg: iPhone X`, `Mac Address`/`Mac address of your device`, `Save`.
|
||||||
|
|
||||||
|
**queries/devices** — `Device name is required and must be at least 2 characters.`, `Validation failed.`, `Authentication required.`, `Device successfully added!`, `An unexpected error occurred.`, `Reason for blocking is required and must be at least 5 characters.`, `Reason is required and must be at least 5 characters.`, `Device blocked successfully!` / `Device unblocked successfully!`.
|
||||||
|
|
||||||
|
## 3. Payments / billing
|
||||||
|
|
||||||
|
**payments page** — `My Subscriptions`; `Payment` (`All`/`Paid`/`Unpaid`), `Payment Method` (`All`/`Transfer`/`Wallet`), `Number of months`; headers `Details`,`Duration`,`Status`,`Amount`.
|
||||||
|
|
||||||
|
**payment detail** — `Payment`; `Payment Pending` / `Payment Expired` / `Payment Cancelled`.
|
||||||
|
|
||||||
|
**user-payments page** — `User Payments`; filters `User`/`Enter user name`, `MIB Reference`/`Enter MIB Reference`, `Amount Range`, `Duration Range`, `Payment Status`, `Payment Method`; `loading....`.
|
||||||
|
|
||||||
|
**payments-table** — `No Payments.`; `View Details`, `Devices`, `Months`, `Expired`, `MVR`; `Total {n} payment(s).`.
|
||||||
|
|
||||||
|
**account-information** — `Account Information`, `Account Name`, `Account No`, `Account number copied!`, `Copy Account Number`.
|
||||||
|
|
||||||
|
**cancel-payment-button** — `Payment cancelled successfully!`, `Your payment of {amount} MVR has been cancelled.`, `Cancel Payment`.
|
||||||
|
|
||||||
|
**cancel-topup-button** — `Topup cancelled successfully!`, `Your topup of {amount} MVR has been cancelled.`, `Cancel Topup`.
|
||||||
|
|
||||||
|
**expiry-time-countdown** — `Time left: {t}`, `{label} has expired.`.
|
||||||
|
|
||||||
|
**user-payments-table** — `No user payments yet.`; headers `Devices paid`,`User`,`Amount`,`Duration`,`Payment Status`,`Payment Method`,`MIB Reference`,`Paid At`,`Action`; `MVR`, `Months`, `Details`; `Total {n} payment(s).`.
|
||||||
|
|
||||||
|
**actions/payment** — `Payment ID is required`, `Payment method is required`, `Payment completed successfully using wallet!`, `Payment verification successful!`, `Unable to verify payment. Please try again or contact support.`, `Topup ID is required`, `Topup payment verified successfully`, `An error occurred.`.
|
||||||
|
|
||||||
|
## 4. Wallet / topups
|
||||||
|
|
||||||
|
**wallet page** — `Transaction History`; `Type` (`All`/`Debit`/`Credit`), `Topup Amount`; headers `Description`,`Amount`,`Transaction Type`,`View Details`.
|
||||||
|
|
||||||
|
**top-ups page** — `My Topups`; `Status` (`All`/`Pending`/`Cancelled`/`Paid`), `Topup Expiry` (`Expired`/`Not Expired`), `Topup Amount`; headers `Details`,`Status`,`Amount`.
|
||||||
|
|
||||||
|
**topup detail** — `Topup`; `Payment Pending` / `Topup Expired` / `Topup Cancelled`.
|
||||||
|
|
||||||
|
**user-topups page** — `User Topups`; `Filter user topups by status, topup expiry, or amount.`; `loading....`.
|
||||||
|
|
||||||
|
**wallet** — `Wallet`, `Your wallet balance is {balance}`, `Set amount to top up`, `Go to payment`, `Cancel`, `Something went wrong.`.
|
||||||
|
|
||||||
|
**wallet-transactions-table** — `No transactions yet.`, `Total Debit`, `Total Credit`; headers `Description`,`Amount`,`Transaction Type`,`View Details`,`Created at`; `Total {n} transaction(s).`.
|
||||||
|
|
||||||
|
**topups-table** — `No topups.`; headers `Details`,`Status`,`Amount`; `View Details`; `Total {n} topup(s).`.
|
||||||
|
|
||||||
|
**topup-to-pay** — `Topup successful!`, `Your topup payment has been verified successfully using {sourceBank} bank transfer on {trxDate}.`, `Topup Payment Verification Failed`, `Please send the following amount to the payment address`, bank `Baraveli Dev` / `90101400028321000`, `Topup Payment Verified`, `I have paid`, `Processing payment...`; rows `Topup created`, `Payment received`, `MIB Reference`, `Total Due`.
|
||||||
|
|
||||||
|
**admin-topup-form** — `Add cash topup`, `New Manual Topup`, `To add a new manual topup, enter the amount below. Click save when you are done.`, `Topup Amount`, `Topup Description`, `Save`.
|
||||||
|
|
||||||
|
**admin-topup-table** — `No topups yet.`; headers `User`,`Status`,`Amount`,`Action`; `View Details`; `Total {n} topup(s).`.
|
||||||
|
|
||||||
|
## 5. Admin / user management
|
||||||
|
|
||||||
|
**users page** — `Users`; `User Filter` / `Filter users by id card, name, or house name and more.`; filters `User First Name`, `User Last Name`, `ID Card`, `House Name`, `Phone Number`, `User Status` (`All`/`Verified`/`Unverified`); `loading....`.
|
||||||
|
|
||||||
|
**user details** — `User Information`; `Update User`, `Update Agreement`, `View Agreement`, `Verified`; sections `Database Information`, `National Information`; fields `ID Card`,`Name`,`House Name`,`Island`,`Atoll`,`DOB`,`Phone Number`; `id photo` (alt).
|
||||||
|
|
||||||
|
**user update page** — `Verify user`.
|
||||||
|
|
||||||
|
**user agreement page** — `Upload user user agreement` (sic).
|
||||||
|
|
||||||
|
**user-table** — `No Users yet.`; headers `Name`,`ID Card`,`Atoll`,`Island`,`House Name`,`Status`,`Dob`,`Phone Number`,`Action`; `Verified`/`Unverified`, `Details`; `Total {n} user(s).`.
|
||||||
|
|
||||||
|
**user-update-form** — `Go Back`, `Update User Information`; labels `ID Card`,`First Name`,`Last Name`,`House Name`,`DOB`,`Phone Number`; `Update User`; toasts `Success` / `User updated successfully`, `Error in {field}: {error}`.
|
||||||
|
|
||||||
|
**user-agreement-form** — `Go Back`, `Upload User agreement`, `Agreement Document`, `Update Agreement`; toasts `Success` / `User agreement updated successfully`, `Error in {field}: {error}`.
|
||||||
|
|
||||||
|
**user-verify-dialog** — `Verify` / `Verified`, `Verify User`, `Are you sure you want to verify the following user?`, dynamic `Name/ID Card/Address/DOB/Phone Number`, `Verifying...`, `User Verified!`, `The following fields do not match`.
|
||||||
|
|
||||||
|
**user-reject-dialog** — `Reject`, `Are you sure?`, dynamic user lines, `Rejection details`, `User rejected successfully!`.
|
||||||
|
|
||||||
|
**actions/user-actions** — `User verification failed`, `An unexpected error occurred.`, `An error occurred while updating the user.`, `User updated successfully`, `An error occurred while updating the user agreement.`, `User agreement updated successfully`, `Amount is required`, `An error occurred while topping up the user.`, `User topped up successfully`.
|
||||||
|
|
||||||
|
## 6. Profile
|
||||||
|
|
||||||
|
**profile page** — `Profile`, `Profile Status`; fields `Full Name`,`ID Card`,`Island`,`Date of Birth`,`Address`,`Phone Number`,`Account Number`; status `Verified`/`Not Verified`/`Unknown`.
|
||||||
|
|
||||||
|
## 7. Layout / navigation / shared
|
||||||
|
|
||||||
|
**root metadata** — `SAR Link Portal` (title), `Sarlink Portal` (description).
|
||||||
|
|
||||||
|
**dashboard error** — `Something went wrong!`, `Try again`.
|
||||||
|
|
||||||
|
**account-popover** — `{first} {last}`, `Logout`, `View Profile`.
|
||||||
|
|
||||||
|
**welcome-banner** — `Welcome, {firstName} {lastName}`.
|
||||||
|
|
||||||
|
**theme-toggle** — `Toggle theme`, `Light`, `Dark`, `System`.
|
||||||
|
|
||||||
|
**generic-filter** — `Filters`, `Select your desired filters here`, `Filter`, `Applying...`, `Apply Filters`, `Clear Filters`, `Cancel`, `Remove`.
|
||||||
|
|
||||||
|
**search** — `Search...`. **pagination** — `...`.
|
||||||
|
|
||||||
|
**client-error-message** — `You do not have permission to perform this action.`, `Please contact the administrator to give you permissions.`, support phone `919-8026`.
|
||||||
|
|
||||||
|
Generic fallbacks: `An unexpected error occurred.`, `An error occurred.`, `Something went wrong!` / `.`, `Please try again or contact support.`, `loading....`.
|
||||||
|
|
||||||
|
## 8. Price calculator
|
||||||
|
|
||||||
|
`Price Calculator`; labels `Initial Price`,`Number of Devices`,`Number of Days`,`Discount Percentage`,`Total`; `Price for {n} device(s) over {d} day(s): MVR {price}`, `Result`.
|
||||||
|
|
||||||
|
## 9. Agreements
|
||||||
|
|
||||||
|
**agreements page** — `Agreements`, `An error occurred while fetching agreements: {error}`, `No agreement found.`, `Print`.
|
||||||
|
|
||||||
|
**agreement-card** — `Sarlink User Agreement`, `User agreement for Sarlink services.`, `View Agreement`.
|
||||||
|
|
||||||
|
## 10. Parental control
|
||||||
|
|
||||||
|
`Parental Control`; `Device Filter` + name/MAC/vendor filters; table `Device Name`,`Mac Address`,`Vendor`,`#`.
|
||||||
|
|
||||||
|
## 11. Validation messages
|
||||||
|
|
||||||
|
**lib/schemas.ts** (`signUpFormSchema`) — `Name is required.`, `ID Card is required`, `Please enter a valid ID Card number.` (`^[A][0-9]{6}$`), `Atoll is required.`, `Island is required.`, `address is required.` (sic), `Date of birth is required.`, `Phone number is required.`, `Please enter a valid phone number` (`^[79][0-9]{2}[0-9]{4}$`), `You must accept the terms and conditions`, `You must accept the privacy policy`, `Account number is required.`, `Please enter a valid account number` (`^(7\d{12}|9\d{16})$`).
|
||||||
|
|
||||||
|
**auth-actions** — `Please enter a valid phone number` (`^[7|9][0-9]{2}-[0-9]{4}$` — hyphenated variant), `You must be at least 18 years old to register.`
|
||||||
|
|
||||||
|
**verify-otp-form** — `Your one-time password must be 6 characters.`
|
||||||
|
|
||||||
|
## 12. Hardcoded contacts / brand / bank
|
||||||
|
|
||||||
|
| String | Type | Location |
|
||||||
|
|---|---|---|
|
||||||
|
| `SAR Link Portal` / `Sarlink Portal` | brand | `app/layout.tsx` |
|
||||||
|
| `SAR Link by OmegaTech Solutions` | company | signup-form |
|
||||||
|
| `SAR Link` | brand | how-to-get-mac |
|
||||||
|
| `Sarlink User Agreement` | brand | agreement-card |
|
||||||
|
| `Baraveli Dev` | bank account name | devices-to-pay, topup-to-pay |
|
||||||
|
| `90101400028321000` | bank account number | devices-to-pay, topup-to-pay |
|
||||||
|
| `9198026` | support phone | how-to-get-mac |
|
||||||
|
| `919-8026` | support phone | client-error-message |
|
||||||
|
| `shihaam` | personal name (in pending-verification message) | auth-actions |
|
||||||
|
| `MVR` | currency | payments/topups/calculator |
|
||||||
|
|
||||||
|
## Cross-cutting notes (for i18n / port)
|
||||||
|
|
||||||
|
- **Pluralization** is inline `count === 1 ? "X." : "Xs."` everywhere — use ICU plurals.
|
||||||
|
- **Interpolation** via bare template literals — extract as named placeholders.
|
||||||
|
- **Two phone regexes**: `^[79][0-9]{2}[0-9]{4}$` (schemas) vs hyphenated `^[7|9][0-9]{2}-[0-9]{4}$` (auth-actions) — reconcile.
|
||||||
|
- **Duplicated label sets** (`Full Name`/`Name`/`First Name`, `DOB`/`Date of Birth`/`Dob`, `House Name`/`Address`) — inconsistent wording/casing for the same concept; unify into shared keys.
|
||||||
|
- **Source-text defects to fix during extraction:** `i undertand`, `Upload user user agreement`, `for you verification`, lowercase `address is required.`, and the hardcoded name `shihaam` in a user-facing message.
|
||||||
|
- **Server-owned copy:** many messages come from `actions/*` / `queries/*` or pass through backend `message`/`detail` — the i18n layer must cover both, and backend-originated strings can't be translated on the frontend.
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# SAR Link Portal — Frontend User Flows & Client State
|
||||||
|
|
||||||
|
Developer reference for the Next.js frontend (`frontend/`). Traces each end-to-end
|
||||||
|
user flow across pages, components, and server actions/queries, listing the backend
|
||||||
|
API endpoint(s) hit at each step, then documents client-side state (Jotai atoms,
|
||||||
|
React Query, and auth/session).
|
||||||
|
|
||||||
|
> **Mid-migration note (read first).** Auth is being moved off `next-auth` onto a
|
||||||
|
> localStorage-based store (`lib/auth-store.ts`). The result is a **split auth model**:
|
||||||
|
>
|
||||||
|
> - **New client login path** (sign-in → OTP) writes the Knox token + user to
|
||||||
|
> `localStorage` and attaches it per-request via an axios interceptor
|
||||||
|
> (`lib/api-client.ts`). No next-auth session cookie is created.
|
||||||
|
> - **Old server actions/queries** (`actions/*.ts`, `queries/*.ts`, and every
|
||||||
|
> dashboard `page.tsx` that gates on admin) still call
|
||||||
|
> `getServerSession(authOptions)` and read `session.apiToken` /
|
||||||
|
> `session.user.is_admin`.
|
||||||
|
>
|
||||||
|
> Because the new login never establishes a next-auth session, `getServerSession`
|
||||||
|
> returns `null` in those server actions/pages unless a legacy next-auth cookie
|
||||||
|
> exists. **This is the central unfinished piece of the migration** — server-side
|
||||||
|
> data fetching and admin gating are not yet wired to the new token. See
|
||||||
|
> [Auth / Session](#auth--session) for detail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
### API client (new path) — `lib/api-client.ts`
|
||||||
|
- `axios.create({ baseURL: "" })` → all requests are **relative** (`/api/...`,
|
||||||
|
`/callback/auth/`); nginx proxies `/api/` and `/callback/` to Django. No API host
|
||||||
|
is baked into the static build, no CORS.
|
||||||
|
- Request interceptor attaches `Authorization: Token <token>` from
|
||||||
|
`getToken()` (reads `localStorage`).
|
||||||
|
- `validateStatus: status < 500` → 4xx bodies are returned to the caller (not thrown).
|
||||||
|
- Response interceptor: on **401**, calls `clearAuth()` and redirects to
|
||||||
|
`/auth/signin?callbackUrl=<current path>`.
|
||||||
|
|
||||||
|
### Server data path (old) — `actions/*.ts`, `queries/*.ts`
|
||||||
|
- Server actions/queries use `fetch(\`${process.env.SARLINK_API_BASE_URL}/...\`)`
|
||||||
|
with `Authorization: Token ${session?.apiToken}` where
|
||||||
|
`session = await getServerSession(authOptions)`.
|
||||||
|
- Some also `revalidatePath(...)` after mutations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 1 — New user registration / onboarding
|
||||||
|
|
||||||
|
| # | Page / route | Component | Action / query | Backend endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/auth/signin` — `app/(auth)/auth/signin/page.tsx` | `components/auth/login-form.tsx` | `signin()` action (`actions/auth-actions.ts`) | — |
|
||||||
|
| 2 | (action) | — | `signin()` checks the phone | `GET /api/auth/users/filter/?mobile=<mobile>` |
|
||||||
|
| 3 | redirect → `/auth/signup?phone_number=<phone>` | — | — | — |
|
||||||
|
| 4 | `/auth/signup` — `app/(auth)/auth/signup/page.tsx` | `components/auth/signup-form.tsx` | `signup()` action | dup checks (below) |
|
||||||
|
| 5 | (action `signup()`) | — | `checkIdOrPhone`, `checkTempIdOrPhone`, `backendRegister` (`queries/authentication.ts`) | `GET /api/auth/users/filter/?id_card=&mobile=`, `GET /api/auth/users/temp/filter/?...`, `POST /api/auth/register/` |
|
||||||
|
| 6 | redirect → `/auth/verify-otp-registration?phone_number=<t_username>` | — | — | — |
|
||||||
|
| 7 | `/auth/verify-otp-registration` — `app/(auth)/auth/verify-otp-registration/page.tsx` | `components/auth/verify-registration-otp-form.tsx` | `VerifyRegistrationOTP()` (`queries/authentication.ts`) | `POST /api/auth/register/verify/` |
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. User enters phone on `/auth/signin` (`login-form.tsx`, field `phoneNumber`,
|
||||||
|
format `^[7|9][0-9]{2}-[0-9]{4}$`). Submits the `signin()` server action.
|
||||||
|
2. `signin()` calls `GET /api/auth/users/filter/?mobile=<digits>` → `{ ok, verified }`.
|
||||||
|
- **If `!ok` (no such user) → `redirect("/auth/signup?phone_number=<phone>")`.**
|
||||||
|
- If `ok && !verified` → returns "account on pending verification" error.
|
||||||
|
- If `ok && verified` → continues to the Login flow (sends OTP; see Flow 2).
|
||||||
|
3. `/auth/signup` requires `?phone_number=`; the phone field is pre-filled. User fills
|
||||||
|
name, ID card (`A######`), atoll, island, address, DOB (≥18 enforced), account
|
||||||
|
number, terms, policy. Validated by `signUpFormSchema` (`lib/schemas.ts`).
|
||||||
|
4. `signup()` (`actions/auth-actions.ts`) runs duplicate checks:
|
||||||
|
- `checkIdOrPhone({ id_card })` → `GET /api/auth/users/filter/?id_card=...`
|
||||||
|
- `checkIdOrPhone({ phone_number })` and `checkTempIdOrPhone({ phone_number })`
|
||||||
|
→ `GET /api/auth/users/filter/` and `GET /api/auth/users/temp/filter/`.
|
||||||
|
5. `backendRegister()` → `POST /api/auth/register/` with body
|
||||||
|
`{ firstname, lastname, username, address, id_card, dob, mobile, island, atoll,
|
||||||
|
acc_no, terms_accepted, policy_accepted }`. Response `{ t_username }`.
|
||||||
|
6. Redirect → `/auth/verify-otp-registration?phone_number=<t_username>`.
|
||||||
|
7. User enters the 6-digit OTP. `VerifyRegistrationOTP()` →
|
||||||
|
`POST /api/auth/register/verify/` with `{ mobile, otp }` → `{ verified, message }`.
|
||||||
|
On success the user is told to log in (no auto-login — the commented-out
|
||||||
|
auto-login is disabled in the source). Account remains pending admin verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 2 — Login (phone → OTP → token/user → session)
|
||||||
|
|
||||||
|
| # | Page / route | Component | Action / query | Backend endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/auth/signin` | `components/auth/login-form.tsx` | `signin()` action | `GET /api/auth/users/filter/?mobile=<mobile>` |
|
||||||
|
| 2 | (action) | — | `signin()` sends OTP | `POST /auth/mobile/` (body `{ mobile }`) |
|
||||||
|
| 3 | redirect → `/auth/verify-otp?phone_number=<mobile>` | — | — | — |
|
||||||
|
| 4 | `/auth/verify-otp` — `app/(auth)/auth/verify-otp/page.tsx` | `components/auth/verify-otp-form.tsx` | inline (`apiClient.post`) | `POST /callback/auth/` (body `{ token: pin }`) |
|
||||||
|
| 5 | client persist + redirect | — | `setAuth()` (`lib/auth-store.ts`) | — |
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. User enters phone on `/auth/signin`. `signin()` calls
|
||||||
|
`GET /api/auth/users/filter/?mobile=<mobile>`. Existing + verified user →
|
||||||
|
proceeds; unknown → redirect to signup (see Flow 1); unverified → error.
|
||||||
|
2. `signin()` sends the login OTP: `POST /auth/mobile/` with `{ mobile }`.
|
||||||
|
(The same `/auth/mobile/` call is also exposed as `backendMobileLogin()`.)
|
||||||
|
3. Redirect → `/auth/verify-otp?phone_number=<mobile>`.
|
||||||
|
4. **`components/auth/verify-otp-form.tsx`** — the new client login. On submit it calls
|
||||||
|
`apiClient.post("/callback/auth/", { token: pin })`. On HTTP 200 the response is
|
||||||
|
`{ token, user }` (Knox token + user object). Non-200 (validateStatus lets <500
|
||||||
|
through) surfaces `body.token[0]` / `body.message` via a `sonner` toast.
|
||||||
|
5. On success: **`setAuth(res.data.token, res.data.user)`** writes to `localStorage`
|
||||||
|
keys `sarlink_token` and `sarlink_user`, then `router.push(callbackUrl || "/devices")`.
|
||||||
|
|
||||||
|
> **Legacy parallel path (still in the tree, not used by the sign-in UI):**
|
||||||
|
> `app/auth.ts` defines a next-auth `CredentialsProvider` whose `authorize()` posts the
|
||||||
|
> **same** `POST /callback/auth/` with `{ token: pin }` and, on 200, returns
|
||||||
|
> `{ ...user, apiToken, expiry }` into the next-auth JWT/session. The next-auth API
|
||||||
|
> route (`app/api/auth/[...nextauth]/route.ts`) is still mounted, but `AuthProvider`
|
||||||
|
> / `SessionProvider` is **no longer rendered** (see below), so nothing drives this
|
||||||
|
> provider from the UI. Server actions that read `getServerSession` depend on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 3 — Buying / registering a device and paying
|
||||||
|
|
||||||
|
Cart is **purely client-side** Jotai state; nothing is persisted until a payment is
|
||||||
|
created.
|
||||||
|
|
||||||
|
### 3a. Register a device
|
||||||
|
| # | Page / route | Component | Action | Endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/devices` — `app/(dashboard)/devices/page.tsx` | `components/user/add-device-dialog.tsx` | `addDeviceAction()` (`queries/devices.ts`) | `POST /api/devices/` |
|
||||||
|
|
||||||
|
- `add-device-dialog.tsx` validates name (≥2 chars) and MAC
|
||||||
|
(`^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$`). `addDeviceAction` posts
|
||||||
|
`{ name, mac, registered: true }`, then `revalidatePath("/devices")`.
|
||||||
|
|
||||||
|
### 3b. Add devices to cart
|
||||||
|
| # | Page / route | Component | Atoms | Endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/devices`, `/user-devices` | `devices-table.tsx`, `device-card.tsx`, `clickable-row.tsx`, `add-devices-to-cart-button.tsx`, `device-cart.tsx` (`DeviceCartDrawer`) | `deviceCartAtom`, `cartDrawerOpenAtom` | none (client only) |
|
||||||
|
|
||||||
|
- Selecting a device toggles it in `deviceCartAtom` (`Device[]`). When the cart is
|
||||||
|
non-empty, `DeviceCartDrawer` (rendered by `ApplicationLayout`) shows a "pay N
|
||||||
|
devices" action. No backend call at this stage.
|
||||||
|
|
||||||
|
### 3c. Create a payment
|
||||||
|
| # | Page / route | Component | Action | Endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/devices-to-pay` — `app/(dashboard)/devices-to-pay/page.tsx` | `components/devices-for-payment.tsx` | `createPayment()` (`actions/payment.ts`) | `POST /api/billing/payment/` |
|
||||||
|
|
||||||
|
- `devices-for-payment.tsx` reads `deviceCartAtom` and a months value
|
||||||
|
(`numberOfMonths` atom, 1–12). Submitting calls `createPayment({ device_ids,
|
||||||
|
number_of_months })` (type `NewPayment`). On success it clears `deviceCartAtom`,
|
||||||
|
resets `numberOfMonths`, and routes to `/payments/<paymentId>`.
|
||||||
|
`createPayment` also `revalidatePath("/devices")`.
|
||||||
|
|
||||||
|
### 3d. Pay a payment — `/payments/[paymentId]`
|
||||||
|
Page `app/(dashboard)/payments/[paymentId]/page.tsx` loads the payment via
|
||||||
|
`getPayment({ id })` → `GET /api/billing/payment/{id}`. The pay UI is
|
||||||
|
`components/devices-to-pay.tsx`; cancel is `components/billing/cancel-payment-button.tsx`.
|
||||||
|
|
||||||
|
Both pay buttons submit the **same** server action
|
||||||
|
`verifyDevicePayment()` (`actions/payment.ts`) via `useActionState`, differing only by
|
||||||
|
a hidden `method` field → `PUT /api/billing/payment/{id}/verify/` with `{ method }`.
|
||||||
|
|
||||||
|
- **Bank TRANSFER (MIB):** hidden `method=TRANSFER`. The UI shows a hardcoded
|
||||||
|
beneficiary account (`accountNo="90101400028321000"`, "Baraveli Dev"); the user
|
||||||
|
transfers manually then clicks "I have paid". **MIB transaction verification is done
|
||||||
|
server-side by Django** — the frontend does *not* perform an MIB lookup; it only
|
||||||
|
sends `method: "TRANSFER"`. (`Payment.mib_reference` is displayed read-only.)
|
||||||
|
- **WALLET balance:** hidden `method=WALLET`, shown only when
|
||||||
|
`user.wallet_balance >= amount` (balance read from `userAtom`). Success toast:
|
||||||
|
"Payment completed successfully using wallet!". Backend deducts from the wallet.
|
||||||
|
- **Cancel:** `cancelPayment({ id })` → `PATCH /api/billing/payment/{id}/cancel/`
|
||||||
|
(only when `status === "PENDING"` and not expired) → status `CANCELLED`, redirect
|
||||||
|
to `/devices`.
|
||||||
|
|
||||||
|
`NewPayment` = `{ device_ids: number[]; number_of_months: number }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 4 — Wallet top-up
|
||||||
|
|
||||||
|
| # | Page / route | Component | Action / query | Endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | any dashboard page (header) | `components/wallet.tsx` (drawer) | — | — |
|
||||||
|
| 2 | (drawer) | `number-input.tsx` | — | — |
|
||||||
|
| 3 | (drawer submit) | `wallet.tsx` | `createTopup()` (`actions/payment.ts`) | `POST /api/billing/topup/` |
|
||||||
|
| 4 | `/top-ups/[topupId]` — `app/(dashboard)/top-ups/[topupId]/page.tsx` | `components/topup-to-pay.tsx` | `getTopup()` | `GET /api/billing/topup/{id}` |
|
||||||
|
| 5 | (topup page) | `topup-to-pay.tsx` | `verifyTopupPayment()` | `PUT /api/billing/topup/{id}/verify/` |
|
||||||
|
| 6 | (topup page) | `components/billing/cancel-topup-button.tsx` | `cancelTopup()` | `PATCH /api/billing/topup/{id}/cancel/` |
|
||||||
|
| 7 | `/wallet` — `app/(dashboard)/wallet/page.tsx` | `components/wallet-transactions-table.tsx` | `getWaleltTransactions()` (`queries/wallet.ts`) | `GET /api/billing/wallet-transactions/` |
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. The wallet button in the header (`ApplicationLayout`) opens the `Wallet` drawer
|
||||||
|
(`WalletDrawerOpenAtom`); it displays `walletBalance` passed from
|
||||||
|
`user.wallet_balance` (read from `userAtom`).
|
||||||
|
2. User sets an amount (`walletTopUpValue` atom; `maxAllowed=5000`, disabled at 0).
|
||||||
|
3. "Go to payment" → `createTopup({ amount })` → `POST /api/billing/topup/`
|
||||||
|
→ `Topup` (`status: "PENDING"`). Routes to `/top-ups/<topup.id>`.
|
||||||
|
4. Topup page loads it via `getTopup({ id })`. Shows beneficiary account, MIB
|
||||||
|
reference (read-only), expiry countdown, and status badges.
|
||||||
|
5. After transferring, user clicks "I have paid" → `verifyTopupPayment()` →
|
||||||
|
`PUT /api/billing/topup/{id}/verify/` (no body). Response includes
|
||||||
|
`transaction { sourceBank, trxDate }`. On success the backend credits the wallet;
|
||||||
|
action `revalidatePath("/top-ups/[topupId]")`.
|
||||||
|
6. Optional cancel (while PENDING & not expired): `cancelTopup({ id })` →
|
||||||
|
`PATCH /api/billing/topup/{id}/cancel/` → status `CANCELLED`.
|
||||||
|
7. History at `/wallet`: `getWaleltTransactions()` →
|
||||||
|
`GET /api/billing/wallet-transactions/` → `WalletTransaction[]`
|
||||||
|
(`transaction_type: "TOPUP" | "DEBIT"`), with per-row links to the source
|
||||||
|
`/top-ups/{ref}` or `/payments/{ref}`.
|
||||||
|
|
||||||
|
> Note: the displayed wallet balance comes from `userAtom` (login snapshot), so it can
|
||||||
|
> be **stale** after a topup/payment until the user logs in again — the header does not
|
||||||
|
> re-fetch the profile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 5 — Parental control
|
||||||
|
|
||||||
|
Page `app/(dashboard)/parental-control/page.tsx` reuses `components/devices-table.tsx`
|
||||||
|
with `parentalControl={true}` and hard filters `is_active: "true"`,
|
||||||
|
`has_a_pending_payment: "false"`, plus a `DynamicFilter` (name / mac / vendor).
|
||||||
|
|
||||||
|
| # | Page / route | Component | Action | Endpoint |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `/parental-control` | `devices-table.tsx` | `getDevices()` (`queries/devices.ts`) | `GET /api/devices/?is_active=true&has_a_pending_payment=false&all_devices=false` |
|
||||||
|
| 2 | (row) | `components/block-device-dialog.tsx` | `blockDeviceAction()` | `PUT /api/devices/{id}/block/` |
|
||||||
|
|
||||||
|
**What it does:** lets a user (parent) block/unblock their own active devices.
|
||||||
|
`blockDeviceAction` (`queries/devices.ts`) branches on the form's `action`
|
||||||
|
(`block` | `simple-block` | `unblock`) and on `session.user.is_superuser`:
|
||||||
|
|
||||||
|
- **Parent block** (`simple-block`, non-admin): body
|
||||||
|
`{ blocked: true, reason_for_blocking: "Blocked by parent", blocked_by: "PARENT" }`.
|
||||||
|
- **Parent unblock** (`unblock`): `{ blocked: false, reason_for_blocking: "-",
|
||||||
|
blocked_by: "PARENT" }`.
|
||||||
|
- **Admin block** (`block`, `is_superuser`): opens a dialog requiring a reason
|
||||||
|
(≥5 chars, validated in the action); body
|
||||||
|
`{ blocked: true, reason_for_blocking: <reason>, blocked_by: "ADMIN" }`.
|
||||||
|
|
||||||
|
After mutating, `revalidatePath("/devices")` and `revalidatePath("/parental-control")`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 6 — Admin management
|
||||||
|
|
||||||
|
**Admin gating (old model):** each admin `page.tsx` runs
|
||||||
|
`const session = await getServerSession(authOptions); if (!session?.user?.is_admin)
|
||||||
|
redirect(...)`. The **sidebar** additionally gates nav items client-side via
|
||||||
|
`userAtom` (`components/ui/app-sidebar.tsx`): if `user.is_admin` all categories show;
|
||||||
|
otherwise the "ADMIN CONTROL" group is dropped and remaining items are filtered by
|
||||||
|
matching `perm_identifier` against `user.user_permissions`.
|
||||||
|
|
||||||
|
> ⚠️ Consistent with the migration gap: server-side `is_admin` comes from
|
||||||
|
> `getServerSession` (next-auth), while the sidebar's `is_admin`/permissions come from
|
||||||
|
> the localStorage `userAtom`. These are two different sources.
|
||||||
|
|
||||||
|
**"See everything" flags:** admin list views pass a second boolean to the query that
|
||||||
|
appends an `all_*` query param:
|
||||||
|
|
||||||
|
| Endpoint | User (default) | Admin |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/devices/` | `all_devices=false` | `all_devices=true` |
|
||||||
|
| `GET /api/billing/payment/` | `all_payments=false` | `all_payments=true` |
|
||||||
|
| `GET /api/billing/topup/` | `all_topups=false` | `all_topups=true` |
|
||||||
|
| `GET /api/billing/wallet-transactions/` | `all_transactions=false` | `all_transactions=true` |
|
||||||
|
|
||||||
|
### 6a. Users — `/users`, `/users/[userId]/{details,update,agreement}`
|
||||||
|
| Page | Component | Action / query | Endpoint |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/users` | `components/user-table.tsx` | `getUsers()` (`queries/users.ts`) | `GET /api/auth/users/?<filters>` |
|
||||||
|
| `/users/[userId]/details` | detail view + dialogs | `getProfileById()` | `GET /api/auth/users/{id}/` |
|
||||||
|
| — verify | `components/user/user-verify-dialog.tsx` | `verifyUser()` (`actions/user-actions.ts`) | `PUT /api/auth/users/{id}/verify/` |
|
||||||
|
| — reject | `components/user/user-reject-dialog.tsx` | `rejectUser()` | `DELETE /api/auth/users/{id}/reject/` (body `{ rejection_details }`) |
|
||||||
|
| — add cash | `components/admin/admin-topup-form.tsx` | `adminUserTopup()` | `POST /api/billing/admin-topup/` (body `{ amount, user_id, description }`) |
|
||||||
|
| `/users/[userId]/update` | `components/user/user-update-form.tsx` | `updateUser()` | `PUT /api/auth/users/{id}/update/` |
|
||||||
|
| `/users/[userId]/agreement` | `components/user/user-agreement-form.tsx` | `updateUserAgreement()` | `PUT /api/auth/users/{id}/agreement/` (multipart file) |
|
||||||
|
|
||||||
|
The details page also compares DB data vs national registry data (`getNationalPerson`,
|
||||||
|
`lib/person.ts` / `lib/types.ts::TNationalPerson`).
|
||||||
|
|
||||||
|
### 6b. User devices — `/user-devices`
|
||||||
|
`components/admin/admin-devices-table.tsx` → `getDevices(params, true)` →
|
||||||
|
`GET /api/devices/?...&all_devices=true`. Block/unblock via `block-device-dialog.tsx`
|
||||||
|
in admin mode (`blocked_by: "ADMIN"`, reason required) → `PUT /api/devices/{id}/block/`.
|
||||||
|
|
||||||
|
### 6c. User payments — `/user-payments`
|
||||||
|
`components/admin/user-payments-table.tsx` → `getPayments(params, true)` →
|
||||||
|
`GET /api/billing/payment/?...&all_payments=true`. Read-only admin view (status,
|
||||||
|
method, MIB reference, paid-at).
|
||||||
|
|
||||||
|
### 6d. User topups — `/user-topups`
|
||||||
|
`components/admin/admin-topup-table.tsx` → `getTopups(params, true)` →
|
||||||
|
`GET /api/billing/topup/?...&all_topups=true`. Manual credit via
|
||||||
|
`admin-topup-form.tsx` → `adminUserTopup()` → `POST /api/billing/admin-topup/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client state
|
||||||
|
|
||||||
|
### Jotai atoms
|
||||||
|
|
||||||
|
**`lib/atoms.ts`** (plain in-memory atoms; a bespoke `store` is exported but the app is
|
||||||
|
wrapped in a default `<Provider>` in `app/layout.tsx`):
|
||||||
|
|
||||||
|
| Atom | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `initialPriceAtom` | `100` | Price calculator: base price input |
|
||||||
|
| `discountPercentageAtom` | `75` | Price calculator: per-extra-device increment (used as the multiplier in the formula) |
|
||||||
|
| `numberOfDevicesAtom` | `1` | Price calculator: device count |
|
||||||
|
| `numberOfDaysAtom` | `30` | Price calculator: days (display only) |
|
||||||
|
| `numberOfMonths` | `1` | Months selected on `devices-to-pay` → `createPayment` |
|
||||||
|
| `walletTopUpValue` | `100` | Wallet drawer top-up amount |
|
||||||
|
| `formulaResultAtom` | `""` | Price calculator: computed result string |
|
||||||
|
| `deviceCartAtom` | `[]` (`Device[]`) | Devices selected for payment (the "cart") |
|
||||||
|
| `cartDrawerOpenAtom` | `false` | Device cart drawer open state |
|
||||||
|
| `WalletDrawerOpenAtom` | `false` | Wallet drawer open state |
|
||||||
|
| `loadingDevicesToPayAtom` | `false` | Loading flag during payment creation |
|
||||||
|
|
||||||
|
Price-calculator atoms are consumed by `components/price-calculator.tsx` and
|
||||||
|
`components/devices-for-payment.tsx`. Cart atoms by the device tables/cards and
|
||||||
|
`device-cart.tsx`. Wallet atoms by `components/wallet.tsx`.
|
||||||
|
|
||||||
|
**`lib/auth-store.ts`** (`atomWithStorage`, persisted to `localStorage`):
|
||||||
|
|
||||||
|
| Atom / key | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `tokenAtom` (key `sarlink_token`) | Knox token, reactive for components |
|
||||||
|
| `userAtom` (key `sarlink_user`) | Logged-in `AuthUser` (id, names, id_card, mobile, `wallet_balance`, `is_admin`, `is_superuser`, `user_permissions`, `expiry`, …) |
|
||||||
|
|
||||||
|
Plus non-React helpers over the same keys: `getToken()` (used by the axios
|
||||||
|
interceptor), `getStoredUser()`, `setAuth(token, user)`, `clearAuth()`,
|
||||||
|
`isAuthenticated()` (token present and, if `user.expiry` known, not past it).
|
||||||
|
|
||||||
|
Consumers of `userAtom`: `application-layout.tsx` (wallet balance, welcome banner),
|
||||||
|
`account-popver.tsx` (profile + logout), `app-sidebar.tsx` (admin/permission gating).
|
||||||
|
`isAuthenticated()`: `route-guard.tsx`, `app/page.tsx`.
|
||||||
|
|
||||||
|
### React Query — `providers/query-provider.tsx`
|
||||||
|
- A single `new QueryClient()` wrapped in `QueryClientProvider`. Mounted in **both**
|
||||||
|
`app/layout.tsx` (root) and the dashboard layout `app/(dashboard)/layout.tsx`.
|
||||||
|
- No default options are configured (default staleness/caching).
|
||||||
|
- **Currently unused for data fetching:** there are no `useQuery`/`useMutation` calls
|
||||||
|
in the app — server data comes from server actions/queries and mutations from
|
||||||
|
`useActionState`/server actions. The provider is scaffolding for a future migration.
|
||||||
|
`@tanstack/react-query` is a dependency but not yet driving any reads.
|
||||||
|
|
||||||
|
### Auth / session (split model, mid-migration)
|
||||||
|
|
||||||
|
**New (active for the browser UI):**
|
||||||
|
- Login writes `{ sarlink_token, sarlink_user }` to `localStorage` via `setAuth`
|
||||||
|
(from `verify-otp-form.tsx`).
|
||||||
|
- `apiClient` attaches `Authorization: Token <token>` per request; 401 → `clearAuth`
|
||||||
|
+ redirect to sign-in.
|
||||||
|
- **`RouteGuard`** (`components/auth/route-guard.tsx`) wraps the dashboard layout and
|
||||||
|
is the client-side replacement for the old next-auth `middleware.ts`: it renders
|
||||||
|
nothing until `isAuthenticated()`, else redirects to
|
||||||
|
`/auth/signin?callbackUrl=<path>`.
|
||||||
|
- `app/page.tsx` redirects to `/devices` or `/auth/signin` based on
|
||||||
|
`isAuthenticated()`.
|
||||||
|
- `AccountPopover` logout: best-effort `POST /auth/logout/` (token via interceptor),
|
||||||
|
then `clearAuth()` and redirect.
|
||||||
|
- **`AuthProvider` / next-auth `SessionProvider` is NOT rendered anywhere** (defined in
|
||||||
|
`providers/AuthProvider.tsx` but unreferenced); the root layout uses only Jotai
|
||||||
|
`<Provider>`, `ThemeProvider`, and `QueryProvider`.
|
||||||
|
|
||||||
|
**Old (still present, used only server-side):**
|
||||||
|
- `app/auth.ts` (`authOptions`) — next-auth `CredentialsProvider` posting
|
||||||
|
`POST /callback/auth/`, JWT strategy (30 min), populating `session.apiToken`,
|
||||||
|
`session.user.is_admin`, `session.user.is_superuser` (types in
|
||||||
|
`app/next-auth.d.ts`). Signs out via `queries/authentication.ts::logout` →
|
||||||
|
`POST /auth/logout/`.
|
||||||
|
- Route still mounted: `app/api/auth/[...nextauth]/route.ts`.
|
||||||
|
- Every `actions/*.ts` and `queries/*.ts` server function and the admin/profile/devices
|
||||||
|
`page.tsx` files call `getServerSession(authOptions)` for the token and `is_admin`.
|
||||||
|
|
||||||
|
**Consequence / action item:** since the browser login no longer creates a next-auth
|
||||||
|
session cookie, `getServerSession` yields `null` server-side, so those server actions
|
||||||
|
send `Authorization: Token undefined` and admin `page.tsx` gates would redirect. The
|
||||||
|
remaining migration work is to route server-side data fetching through the localStorage
|
||||||
|
token (or otherwise re-establish the session) — until then the two halves of auth are
|
||||||
|
inconsistent.
|
||||||
|
|
||||||
|
Files using `getServerSession` in the dashboard: `users/page.tsx`,
|
||||||
|
`users/[userId]/update/page.tsx`, `users/[userId]/agreement/page.tsx`,
|
||||||
|
`user-devices/page.tsx`, `user-payments/page.tsx`, `user-topups/page.tsx`,
|
||||||
|
`devices/page.tsx`, `profile/page.tsx` — plus all of `actions/` and `queries/`.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="antialiased" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Sarlink Portal" />
|
||||||
|
<title>SAR Link Portal</title>
|
||||||
|
<script>
|
||||||
|
// Set theme BEFORE first paint to avoid a flash of white.
|
||||||
|
// The white flash is the browser's default canvas, shown before CSS
|
||||||
|
// paints (paint is delayed by the render-blocking font stylesheet).
|
||||||
|
// `color-scheme` controls that pre-paint canvas, so setting it here —
|
||||||
|
// synchronously, before the font <link> below can block — fixes it.
|
||||||
|
// Mirrors next-themes ("theme" key; falls back to system preference).
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem("theme");
|
||||||
|
var isDark =
|
||||||
|
stored === "dark" ||
|
||||||
|
((!stored || stored === "system") &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.classList.toggle("dark", isDark);
|
||||||
|
root.style.colorScheme = isDark ? "dark" : "light";
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:wght@100;300;400;500;600;700;800;900&family=Bokor&family=Geist+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased font-sans bg-gray-100 dark:bg-black">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"use server";
|
|
||||||
import type { TNationalPerson } from "@/lib/types";
|
|
||||||
|
|
||||||
export async function getNationalPerson({
|
|
||||||
idCard,
|
|
||||||
}: {
|
|
||||||
idCard: string;
|
|
||||||
}): Promise<TNationalPerson> {
|
|
||||||
const nationalInformation = await fetch(
|
|
||||||
`${process.env.PERSON_VERIFY_BASE_URL}/api/person/${idCard}`,
|
|
||||||
{
|
|
||||||
next: {
|
|
||||||
revalidate: 60,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const nationalData = (await nationalInformation.json()) as TNationalPerson;
|
|
||||||
return nationalData;
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
export type TopupType = {
|
|
||||||
amount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Transaction = {
|
|
||||||
ref: string;
|
|
||||||
sourceBank: string;
|
|
||||||
trxDate: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TopupResponse = {
|
|
||||||
status: boolean;
|
|
||||||
message: string;
|
|
||||||
transaction?: Transaction;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IpAddress {
|
|
||||||
ip: string;
|
|
||||||
mask: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Ipv6Address {
|
|
||||||
ip: string;
|
|
||||||
prefix: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MacAddress {
|
|
||||||
ruleId?: number;
|
|
||||||
name: string;
|
|
||||||
macAddress: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupProfile {
|
|
||||||
groupId: string;
|
|
||||||
site?: string;
|
|
||||||
name: string;
|
|
||||||
buildIn?: boolean;
|
|
||||||
ipList?: IpAddress[];
|
|
||||||
ipv6List?: Ipv6Address[];
|
|
||||||
macAddressList?: MacAddress[];
|
|
||||||
count: number;
|
|
||||||
type: number;
|
|
||||||
resource: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OmadaResponse {
|
|
||||||
errorCode: number;
|
|
||||||
msg: string;
|
|
||||||
result: {
|
|
||||||
data: GroupProfile[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TNationalPerson {
|
|
||||||
nic: string;
|
|
||||||
name: string;
|
|
||||||
name_en: string;
|
|
||||||
dob: string;
|
|
||||||
gender: "M" | "F"; // Assuming gender can only be Male or Female
|
|
||||||
house_name: string;
|
|
||||||
house_name_en: string;
|
|
||||||
island_name: string;
|
|
||||||
island_name_en: string;
|
|
||||||
atoll: string;
|
|
||||||
atoll_en: string;
|
|
||||||
constituency: string;
|
|
||||||
district_en: string | null;
|
|
||||||
block_no: string | null;
|
|
||||||
email: string | null;
|
|
||||||
primary_contact: string;
|
|
||||||
image_url: string;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ISODateString } from "next-auth";
|
|
||||||
import type { Atoll, Island } from "../backend-types";
|
|
||||||
|
|
||||||
export interface Permission {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TAuthUser {
|
|
||||||
expiry?: string;
|
|
||||||
token?: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
user_permissions: Permission[];
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
id_card?: string;
|
|
||||||
address?: string;
|
|
||||||
verified?: boolean;
|
|
||||||
dob?: string;
|
|
||||||
mobile?: string;
|
|
||||||
wallet_balance?: number;
|
|
||||||
is_superuser: boolean;
|
|
||||||
date_joined: string;
|
|
||||||
last_login: string;
|
|
||||||
agreement?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserProfile {
|
|
||||||
id: number;
|
|
||||||
email: string;
|
|
||||||
first_name: string;
|
|
||||||
last_name: string;
|
|
||||||
atoll: Atoll;
|
|
||||||
island: Island;
|
|
||||||
dob: string;
|
|
||||||
verified: boolean;
|
|
||||||
username: string;
|
|
||||||
mobile: string;
|
|
||||||
address: string;
|
|
||||||
acc_no: string;
|
|
||||||
id_card: string;
|
|
||||||
agreement: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
user?: {
|
|
||||||
token?: string;
|
|
||||||
name?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
image?: string | null;
|
|
||||||
user?: User & {
|
|
||||||
expiry?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
expires: ISODateString;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatDate = (date: Date): string => {
|
|
||||||
const pad = (num: number): string => num.toString().padStart(2, "0");
|
|
||||||
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = pad(date.getMonth() + 1); // Months are zero-based
|
|
||||||
const day = pad(date.getDate());
|
|
||||||
const hours = pad(date.getHours());
|
|
||||||
const minutes = pad(date.getMinutes() + 5);
|
|
||||||
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatMacAddress = (mac: string): string => {
|
|
||||||
const formatted = mac
|
|
||||||
.replace(/[^A-Fa-f0-9]/g, "")
|
|
||||||
.toUpperCase()
|
|
||||||
.match(/.{2}/g);
|
|
||||||
|
|
||||||
// Provide a fallback if formatted is null
|
|
||||||
return formatted ? formatted.join("-") : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateApiKey(request: Request) {
|
|
||||||
// Get API key from environment variable
|
|
||||||
const validApiKey = process.env.CRON_API_KEY;
|
|
||||||
|
|
||||||
if (!validApiKey) {
|
|
||||||
throw new Error("CRON_API_KEY is not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get API key from request header
|
|
||||||
const apiKey = request.headers.get("x-api-key");
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error("API key is missing");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiKey !== validApiKey) {
|
|
||||||
throw new Error("Invalid API key");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { withAuth } from "next-auth/middleware";
|
|
||||||
|
|
||||||
export default withAuth(
|
|
||||||
// `withAuth` augments your `Request` with the user's token.
|
|
||||||
function middleware(req) {},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ["/about/:path*", "/dashboard/:path*", "/devices/:path*"],
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
experimental: {
|
|
||||||
serverActions: {
|
|
||||||
bodySizeLimit: "20mb",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
new URL("http://people-api.sarlink.net/images/**"),
|
|
||||||
new URL("http://verifypersonapi.baraveli.dev/images/**"),
|
|
||||||
new URL("https://i.pravatar.cc/300/**"),
|
|
||||||
new URL("https://sarlink-portal.vercel.app/**"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
output: "standalone",
|
|
||||||
async headers() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/:path*{/}?",
|
|
||||||
headers: [
|
|
||||||
{
|
|
||||||
key: "X-Accel-Buffering",
|
|
||||||
value: "no",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,54 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "sarlink-portal",
|
"name": "sarlink-portal-webui",
|
||||||
"version": "0.2.2",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.3.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "next start",
|
"lint": "oxlint",
|
||||||
"lint": "next lint",
|
"preview": "vite preview"
|
||||||
"prepare": "husky",
|
|
||||||
"release": "release-it"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@commitlint/cli": "^19.8.1",
|
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
|
||||||
"@faker-js/faker": "^9.3.0",
|
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@pyncz/tailwind-mask-image": "^2.0.0",
|
"@pyncz/tailwind-mask-image": "^2.0.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
|
||||||
"@tanstack/react-query": "^5.61.4",
|
"@tanstack/react-query": "^5.61.4",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"commitlint": "^19.8.1",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"husky": "^9.1.7",
|
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jotai": "2.8.0",
|
"jotai": "2.8.0",
|
||||||
"lucide-react": "^0.523.0",
|
"lucide-react": "^0.523.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"motion": "^12.15.0",
|
"motion": "^12.15.0",
|
||||||
"next": "15.3.3",
|
|
||||||
"next-auth": "^4.24.11",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.7.15",
|
|
||||||
"nuqs": "^2.4.3",
|
"nuqs": "^2.4.3",
|
||||||
"radix-ui": "^1.4.2",
|
"radix-ui": "^1.4.2",
|
||||||
"react": "19.1.0",
|
"react": "^19.2.8",
|
||||||
"react-aria-components": "^1.5.0",
|
"react-aria-components": "^1.5.0",
|
||||||
"react-day-picker": "^9.7.0",
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
"react-phone-number-input": "^3.4.9",
|
"react-phone-number-input": "^3.4.9",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
"react-router-dom": "^7.1.1",
|
||||||
"recharts": "^3.0.0",
|
"recharts": "^3.0.0",
|
||||||
"release-it": "^19.0.5",
|
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -56,69 +45,16 @@
|
|||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.6",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@release-it/conventional-changelog": "^10.0.1",
|
"@types/node": "^24.13.3",
|
||||||
"@types/node": "^22.10.2",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.35.0",
|
"oxlint": "^1.75.0",
|
||||||
"@typescript-eslint/parser": "^8.35.0",
|
|
||||||
"eslint": "^9.29.0",
|
|
||||||
"eslint-config-next": "15.1.2",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tailwindcss-motion": "^1.1.0",
|
"tailwindcss-motion": "^1.1.0",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.4",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "~6.0.2",
|
||||||
},
|
"vite": "^8.2.0"
|
||||||
"release-it": {
|
|
||||||
"git": {
|
|
||||||
"commitMessage": "chore: release v${version}"
|
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"release": true
|
|
||||||
},
|
|
||||||
"npm": {
|
|
||||||
"publish": false
|
|
||||||
},
|
|
||||||
"plugins": {
|
|
||||||
"@release-it/conventional-changelog": {
|
|
||||||
"infile": "CHANGELOG.md",
|
|
||||||
"preset": {
|
|
||||||
"name": "conventionalcommits",
|
|
||||||
"types": [
|
|
||||||
{
|
|
||||||
"type": "feat",
|
|
||||||
"section": "Features"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "fix",
|
|
||||||
"section": "Bug Fixes"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "chore",
|
|
||||||
"section": "Chores"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "docs",
|
|
||||||
"section": "Documentation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "refactor",
|
|
||||||
"section": "Refactor"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "style",
|
|
||||||
"section": "Style"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "test",
|
|
||||||
"section": "Tests"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/postcss": {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { SessionProvider } from "next-auth/react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
session?: Session;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session }: Props) => {
|
|
||||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
|
||||||
};
|
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
|||||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
@@ -1,201 +0,0 @@
|
|||||||
"use server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import type {
|
|
||||||
ActionState,
|
|
||||||
FilterTempUserResponse,
|
|
||||||
FilterUserResponse,
|
|
||||||
} from "@/actions/auth-actions";
|
|
||||||
import type { TAuthUser, User } from "@/lib/types/user";
|
|
||||||
import axiosInstance from "@/utils/axiosInstance";
|
|
||||||
import { handleApiResponse } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
export async function login({
|
|
||||||
password,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}): Promise<TAuthUser> {
|
|
||||||
const response = await axiosInstance
|
|
||||||
.post("/auth/login/", {
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
console.log(res);
|
|
||||||
return res.data; // Return the data from the response
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.log(err.response);
|
|
||||||
throw err; // Throw the error to maintain the Promise rejection
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout({ token }: { token: string }) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/auth/logout/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${token}`, // Include the token for authentication
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status !== 204) {
|
|
||||||
throw new Error("Failed to log out from the backend");
|
|
||||||
}
|
|
||||||
console.log("logout res in backend", response);
|
|
||||||
|
|
||||||
// Since the API endpoint returns 204 No Content on success, we don't need to parse JSON
|
|
||||||
return null; // Return null to indicate a successful logout with no content
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkIdOrPhone({
|
|
||||||
id_card,
|
|
||||||
phone_number,
|
|
||||||
}: {
|
|
||||||
id_card?: string;
|
|
||||||
phone_number?: string;
|
|
||||||
}) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/filter/?id_card=${id_card}&mobile=${phone_number}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = (await response.json()) as FilterUserResponse;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkTempIdOrPhone({
|
|
||||||
id_card,
|
|
||||||
phone_number,
|
|
||||||
}: {
|
|
||||||
id_card?: string;
|
|
||||||
phone_number?: string;
|
|
||||||
}) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/temp/filter/?id_card=${id_card}&mobile=${phone_number}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = (await response.json()) as FilterTempUserResponse;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TSignupUser = Pick<
|
|
||||||
User,
|
|
||||||
"username" | "address" | "mobile" | "id_card" | "dob"
|
|
||||||
> & {
|
|
||||||
firstname: string;
|
|
||||||
lastname: string;
|
|
||||||
atoll: number;
|
|
||||||
island: number;
|
|
||||||
acc_no: string;
|
|
||||||
terms_accepted: boolean;
|
|
||||||
policy_accepted: boolean;
|
|
||||||
};
|
|
||||||
export async function backendRegister({ payload }: { payload: TSignupUser }) {
|
|
||||||
console.log("backendRegister payload", payload);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/register/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
console.log("backendRegister response", response);
|
|
||||||
return handleApiResponse<{ t_username: string }>(response, "backendRegister");
|
|
||||||
}
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
mobile: z.string().regex(/^[79]\d{6}$/, "Please enter a valid phone number"),
|
|
||||||
otp: z
|
|
||||||
.string()
|
|
||||||
.min(6, {
|
|
||||||
message: "OTP is required.",
|
|
||||||
})
|
|
||||||
.max(6, {
|
|
||||||
message: "OTP is required.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function VerifyRegistrationOTP(
|
|
||||||
_actionState: ActionState,
|
|
||||||
formData: FormData,
|
|
||||||
) {
|
|
||||||
const formValues = Object.fromEntries(formData.entries());
|
|
||||||
const result = formSchema.safeParse(formValues);
|
|
||||||
console.log("formValues", formValues);
|
|
||||||
if (!result.success) {
|
|
||||||
return {
|
|
||||||
message: result.error.errors[0].message, // Get the error message from Zod
|
|
||||||
status: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formValues.otp === "") {
|
|
||||||
return {
|
|
||||||
message: "OTP is required.",
|
|
||||||
status: "error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mobile, otp } = formValues;
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/register/verify/`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
mobile: mobile,
|
|
||||||
otp: otp as string,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const responseJson = await response.json();
|
|
||||||
console.log("responseJson", responseJson);
|
|
||||||
const data = responseJson as { message: string; verified: boolean };
|
|
||||||
|
|
||||||
if (data.verified) {
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
"Your account has been successfully verified! You may login now.",
|
|
||||||
status: "verify_success",
|
|
||||||
};
|
|
||||||
// const [mobileLoginError, mobileLoginResponse] = await tryCatch(
|
|
||||||
// backendMobileLogin({ mobile: mobile as string }),
|
|
||||||
// );
|
|
||||||
// if (mobileLoginError) {
|
|
||||||
// return {
|
|
||||||
// message: "Login Failed. Please contact support.",
|
|
||||||
// status: "login_error",
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
// if (mobileLoginResponse) {
|
|
||||||
// redirect(`/auth/verify-otp?phone_number=${mobile}`);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
message:
|
|
||||||
"Your account could not be verified. Please wait for you verification to be processed.",
|
|
||||||
status: "verify_error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { AxiosClient } from "@/utils/axios-client";
|
|
||||||
|
|
||||||
export async function getIslands() {
|
|
||||||
const response = await AxiosClient.get("/islands/");
|
|
||||||
const data = response.data;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAtolls() {
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/atolls/`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const data = response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllItems({
|
|
||||||
limit = 10,
|
|
||||||
offset = 0,
|
|
||||||
...otherParams
|
|
||||||
}: {
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
} & Record<string, unknown>) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
// Add default params
|
|
||||||
params.append("limit", limit.toString());
|
|
||||||
params.append("offset", offset.toString());
|
|
||||||
|
|
||||||
// Add any additional params dynamically
|
|
||||||
Object.entries(otherParams).map(([key, value]) => {
|
|
||||||
if (value !== undefined) {
|
|
||||||
params.append(key, String(value || ""));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await AxiosClient.get(`/inventory/?${params.toString()}`);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"use server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import type { ApiResponse } from "@/lib/backend-types";
|
|
||||||
import type { UserProfile } from "@/lib/types/user";
|
|
||||||
import { handleApiResponse } from "@/utils/tryCatch";
|
|
||||||
|
|
||||||
type ParamProps = {
|
|
||||||
[key: string]: string | number | undefined;
|
|
||||||
};
|
|
||||||
export async function getUsers(params?: ParamProps) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
const query = Object.entries(params ?? {})
|
|
||||||
.filter(([_, value]) => value !== undefined && value !== "")
|
|
||||||
.map(
|
|
||||||
([key, value]) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
|
||||||
)
|
|
||||||
.join("&");
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/?${query}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return handleApiResponse<ApiResponse<UserProfile>>(response, "getUsers");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProfileById(userId: string) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/auth/users/${userId}/`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return handleApiResponse<UserProfile>(response, "getProfilebyId");
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { authOptions } from "@/app/auth";
|
|
||||||
import type {
|
|
||||||
ApiError,
|
|
||||||
ApiResponse,
|
|
||||||
WalletTransaction,
|
|
||||||
} from "@/lib/backend-types";
|
|
||||||
|
|
||||||
type GenericGetResponseProps = {
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
page?: number;
|
|
||||||
[key: string]: string | number | undefined;
|
|
||||||
};
|
|
||||||
export async function getWaleltTransactions(
|
|
||||||
params: GenericGetResponseProps,
|
|
||||||
allTransactions = false,
|
|
||||||
) {
|
|
||||||
// Build query string from all defined params
|
|
||||||
const query = Object.entries(params)
|
|
||||||
.filter(([_, value]) => value !== undefined && value !== "")
|
|
||||||
.map(
|
|
||||||
([key, value]) =>
|
|
||||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
|
||||||
)
|
|
||||||
.join("&");
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const response = await fetch(
|
|
||||||
`${process.env.SARLINK_API_BASE_URL}/api/billing/wallet-transactions/?${query}&all_transactions=${allTransactions}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Token ${session?.apiToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = (await response.json()) as ApiError;
|
|
||||||
const errorMessage =
|
|
||||||
errorData.message || errorData.detail || "An error occurred.";
|
|
||||||
const error = new Error(errorMessage);
|
|
||||||
(error as ApiError & { details?: ApiError }).details = errorData; // Attach the errorData to the error object
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as ApiResponse<WalletTransaction>;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { signUpFormSchema } from "@/lib/schemas";
|
||||||
|
import {
|
||||||
|
backendRegister,
|
||||||
|
checkIdOrPhone,
|
||||||
|
checkTempIdOrPhone,
|
||||||
|
} from "@/queries/authentication";
|
||||||
|
import apiClient from "@/lib/api-client";
|
||||||
|
import { tryCatch } from "@/utils/tryCatch";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
phoneNumber: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[7|9][0-9]{2}-[0-9]{4}$/, "Please enter a valid phone number"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FilterUserResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
export type FilterTempUserResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
otp_verified: boolean;
|
||||||
|
t_verified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In the SPA port, actions can't `redirect()` server-side. Instead they return
|
||||||
|
* a `redirectTo` in their state and the form component navigates to it.
|
||||||
|
*/
|
||||||
|
export async function signin(_previousState: ActionState, formData: FormData) {
|
||||||
|
const phoneNumber = formData.get("phoneNumber") as string;
|
||||||
|
const result = formSchema.safeParse({ phoneNumber });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
message: result.error.errors[0].message,
|
||||||
|
status: "error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return {
|
||||||
|
message: "Please enter a phone number",
|
||||||
|
status: "error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const FORMATTED_MOBILE_NUMBER = `${phoneNumber.split("-").join("")}`;
|
||||||
|
|
||||||
|
const user = await apiClient.get(
|
||||||
|
`/api/auth/users/filter/?mobile=${FORMATTED_MOBILE_NUMBER}`,
|
||||||
|
);
|
||||||
|
const userData = user.data as FilterUserResponse;
|
||||||
|
|
||||||
|
if (!userData.ok) {
|
||||||
|
// No real account yet. If a registration is pending OTP verification
|
||||||
|
// (user registered but never entered the OTP), resend the code and send
|
||||||
|
// them to the OTP page instead of the signup form — otherwise they'd be
|
||||||
|
// stuck: signup rejects the already-registered mobile.
|
||||||
|
const temp = await checkTempIdOrPhone({
|
||||||
|
phone_number: FORMATTED_MOBILE_NUMBER,
|
||||||
|
});
|
||||||
|
if (temp.ok && !temp.otp_verified) {
|
||||||
|
await apiClient.post("/api/auth/register/resend-otp/", {
|
||||||
|
mobile: FORMATTED_MOBILE_NUMBER,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "redirect",
|
||||||
|
redirectTo: `/auth/verify-otp-registration?phone_number=${FORMATTED_MOBILE_NUMBER}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "redirect",
|
||||||
|
redirectTo: `/auth/signup?phone_number=${phoneNumber}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!userData.verified) {
|
||||||
|
// Admin asked this user to upload an ID -> take them straight to the
|
||||||
|
// upload page (mint a fresh single-use token) instead of a dead-end.
|
||||||
|
if (userData.status === "id_required") {
|
||||||
|
const startRes = await apiClient.post("/api/auth/id-upload/start/", {
|
||||||
|
mobile: FORMATTED_MOBILE_NUMBER,
|
||||||
|
});
|
||||||
|
const token = (startRes.data as { token?: string })?.token;
|
||||||
|
if (startRes.status >= 200 && startRes.status < 300 && token) {
|
||||||
|
return {
|
||||||
|
status: "redirect",
|
||||||
|
redirectTo: `/upload-id?token=${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (userData.status === "id_submitted") {
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"Your ID has been submitted and is awaiting review. We'll notify you by SMS once it's checked.",
|
||||||
|
status: "error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
"Your account is on pending verification. Please wait for a response from admin or contact shihaam.",
|
||||||
|
status: "error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.post("/auth/mobile/", { mobile: FORMATTED_MOBILE_NUMBER });
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "redirect",
|
||||||
|
redirectTo: `/auth/verify-otp?phone_number=${FORMATTED_MOBILE_NUMBER}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionState = {
|
||||||
|
status?: string;
|
||||||
|
redirectTo?: string;
|
||||||
|
message?: string;
|
||||||
|
payload?: FormData;
|
||||||
|
errors?: z.typeToFlattenedError<
|
||||||
|
{
|
||||||
|
id_card: string;
|
||||||
|
phone_number: string;
|
||||||
|
name: string;
|
||||||
|
atoll_id: string;
|
||||||
|
island_id: string;
|
||||||
|
address: string;
|
||||||
|
dob: Date;
|
||||||
|
terms: string;
|
||||||
|
policy: string;
|
||||||
|
accNo: string;
|
||||||
|
},
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
db_error?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function signup(_actionState: ActionState, formData: FormData) {
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
const parsedData = signUpFormSchema.safeParse(data);
|
||||||
|
|
||||||
|
if (!parsedData.success) {
|
||||||
|
return {
|
||||||
|
message: "Invalid form data",
|
||||||
|
payload: formData,
|
||||||
|
errors: parsedData.error.flatten(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const age =
|
||||||
|
new Date().getFullYear() - new Date(parsedData.data.dob).getFullYear();
|
||||||
|
if (age < 18) {
|
||||||
|
return {
|
||||||
|
message: "You must be at least 18 years old to register.",
|
||||||
|
payload: formData,
|
||||||
|
db_error: "dob",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const idCardExists = await checkIdOrPhone({
|
||||||
|
id_card: parsedData.data.id_card,
|
||||||
|
});
|
||||||
|
if (idCardExists.ok) {
|
||||||
|
return {
|
||||||
|
message: "ID card already exists.",
|
||||||
|
payload: formData,
|
||||||
|
db_error: "id_card",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumberExists = await checkIdOrPhone({
|
||||||
|
phone_number: parsedData.data.phone_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempPhoneNumberExists = await checkTempIdOrPhone({
|
||||||
|
phone_number: parsedData.data.phone_number,
|
||||||
|
});
|
||||||
|
if (phoneNumberExists.ok || tempPhoneNumberExists.ok) {
|
||||||
|
return {
|
||||||
|
message: "Phone number already exists.",
|
||||||
|
payload: formData,
|
||||||
|
db_error: "phone_number",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [signupError, signupResponse] = await tryCatch(
|
||||||
|
backendRegister({
|
||||||
|
payload: {
|
||||||
|
firstname: parsedData.data.name.split(" ")[0],
|
||||||
|
lastname: parsedData.data.name.split(" ").slice(1).join(" "),
|
||||||
|
username: parsedData.data.phone_number,
|
||||||
|
address: parsedData.data.address,
|
||||||
|
id_card: parsedData.data.id_card,
|
||||||
|
dob: new Date(parsedData.data.dob).toISOString().split("T")[0],
|
||||||
|
mobile: parsedData.data.phone_number,
|
||||||
|
island: Number.parseInt(parsedData.data.island_id),
|
||||||
|
atoll: Number.parseInt(parsedData.data.atoll_id),
|
||||||
|
acc_no: parsedData.data.accNo,
|
||||||
|
terms_accepted: parsedData.data.terms,
|
||||||
|
policy_accepted: parsedData.data.policy,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (signupError) {
|
||||||
|
return {
|
||||||
|
message: signupError.message,
|
||||||
|
payload: formData,
|
||||||
|
db_error: "phone_number",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "User created successfully",
|
||||||
|
error: "success",
|
||||||
|
status: "redirect",
|
||||||
|
redirectTo: `/auth/verify-otp-registration?phone_number=${encodeURIComponent(
|
||||||
|
signupResponse.t_username,
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type {
|
||||||
|
ApiError,
|
||||||
|
ApiResponse,
|
||||||
|
NewPayment,
|
||||||
|
Payment,
|
||||||
|
Topup,
|
||||||
|
} from "@/lib/backend-types";
|
||||||
|
import type { TopupResponse } from "@/lib/types";
|
||||||
|
import apiClient from "@/lib/api-client";
|
||||||
|
import { handleApiResponse } from "@/utils/tryCatch";
|
||||||
|
|
||||||
|
type GenericGetResponseProps = {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, string | number | undefined>) {
|
||||||
|
return Object.entries(params)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== "")
|
||||||
|
.map(
|
||||||
|
([key, value]) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
||||||
|
)
|
||||||
|
.join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment(data: NewPayment) {
|
||||||
|
const response = await apiClient.post("/api/billing/payment/", data);
|
||||||
|
return handleApiResponse<Payment>(response, "createPayment");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTopup(data: { amount: number }) {
|
||||||
|
const response = await apiClient.post("/api/billing/topup/", data);
|
||||||
|
return handleApiResponse<Topup>(response, "createTopup");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPayment({ id }: { id: string }) {
|
||||||
|
const response = await apiClient.get(`/api/billing/payment/${id}`);
|
||||||
|
return handleApiResponse<Payment>(response, "getPayment");
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetPaymentProps = {
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getPayments(params: GetPaymentProps, allPayments = false) {
|
||||||
|
const query = buildQuery(params);
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/api/billing/payment/?${query}&all_payments=${allPayments}`,
|
||||||
|
);
|
||||||
|
return handleApiResponse<ApiResponse<Payment>>(response, "getPayments");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopups(
|
||||||
|
params: GenericGetResponseProps,
|
||||||
|
all_topups = false,
|
||||||
|
) {
|
||||||
|
const query = buildQuery(params);
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/api/billing/topup/?${query}&all_topups=${all_topups}`,
|
||||||
|
);
|
||||||
|
return handleApiResponse<ApiResponse<Topup>>(response, "getTopups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopup({ id }: { id: string }) {
|
||||||
|
const response = await apiClient.get(`/api/billing/topup/${id}`);
|
||||||
|
return handleApiResponse<Topup>(response, "getTopup");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelTopup({ id }: { id: string }) {
|
||||||
|
const response = await apiClient.patch(`/api/billing/topup/${id}/cancel/`);
|
||||||
|
return handleApiResponse<Topup>(response, "cancelTopup");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelPayment({ id }: { id: string }) {
|
||||||
|
const response = await apiClient.patch(`/api/billing/payment/${id}/cancel/`);
|
||||||
|
return handleApiResponse<Payment>(response, "cancelPayment");
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePayment = {
|
||||||
|
id: string;
|
||||||
|
method: "TRANSFER" | "WALLET";
|
||||||
|
};
|
||||||
|
export async function verifyPayment({ id, method }: UpdatePayment) {
|
||||||
|
const response = await apiClient.put(`/api/billing/payment/${id}/verify/`, {
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
return handleApiResponse<Payment>(response, "verifyPayment");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerifyDevicePaymentState = {
|
||||||
|
payment?: Payment;
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
payload?: FormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function verifyDevicePayment(
|
||||||
|
_prevState: VerifyDevicePaymentState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<VerifyDevicePaymentState> {
|
||||||
|
const paymentId = formData.get("paymentId") as string;
|
||||||
|
const method = formData.get("method") as "TRANSFER" | "WALLET";
|
||||||
|
|
||||||
|
if (!paymentId) {
|
||||||
|
return {
|
||||||
|
message: "Payment ID is required",
|
||||||
|
success: false,
|
||||||
|
fieldErrors: { paymentId: "Payment ID is required" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
return {
|
||||||
|
message: "Payment method is required",
|
||||||
|
success: false,
|
||||||
|
fieldErrors: { method: "Payment method is required" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/api/billing/payment/${paymentId}/verify/`,
|
||||||
|
{ method },
|
||||||
|
);
|
||||||
|
const result = handleApiResponse<Payment>(response, "verifyDevicePayment");
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
method === "WALLET"
|
||||||
|
? "Payment completed successfully using wallet!"
|
||||||
|
: "Payment verification successful!",
|
||||||
|
success: true,
|
||||||
|
fieldErrors: {},
|
||||||
|
payment: result,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const fallback =
|
||||||
|
"Unable to verify payment. Please try again or contact support.";
|
||||||
|
return {
|
||||||
|
message: error instanceof Error ? error.message || fallback : fallback,
|
||||||
|
success: false,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerifyTopupPaymentState = {
|
||||||
|
transaction?: {
|
||||||
|
sourceBank: string;
|
||||||
|
trxDate: string;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
payload?: FormData;
|
||||||
|
};
|
||||||
|
export async function verifyTopupPayment(
|
||||||
|
_prevState: VerifyTopupPaymentState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<VerifyTopupPaymentState> {
|
||||||
|
const topupId = formData.get("topupId") as string;
|
||||||
|
|
||||||
|
if (!topupId) {
|
||||||
|
return {
|
||||||
|
message: "Topup ID is required",
|
||||||
|
success: false,
|
||||||
|
fieldErrors: { topupId: "Topup ID is required" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/api/billing/topup/${topupId}/verify/`,
|
||||||
|
);
|
||||||
|
const result = handleApiResponse<TopupResponse>(
|
||||||
|
response,
|
||||||
|
"verifyTopupPayment",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: result.message || "Topup payment verified successfully",
|
||||||
|
success: true,
|
||||||
|
fieldErrors: {},
|
||||||
|
transaction: result.transaction,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const fallback =
|
||||||
|
"Unable to verify payment. Please try again or contact support.";
|
||||||
|
return {
|
||||||
|
message: error instanceof Error ? error.message || fallback : fallback,
|
||||||
|
success: false,
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import type { ApiError } from "@/lib/backend-types";
|
||||||
|
import type { User } from "@/lib/types/user";
|
||||||
|
import apiClient from "@/lib/api-client";
|
||||||
|
import { handleApiResponse } from "@/utils/tryCatch";
|
||||||
|
|
||||||
|
type VerifyUserResponse =
|
||||||
|
| {
|
||||||
|
ok: boolean;
|
||||||
|
mismatch_fields: string[] | null;
|
||||||
|
error: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
message: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function verifyUser(userId: string) {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.put(`/api/auth/users/${userId}/verify/`);
|
||||||
|
const body = (r.data ?? {}) as VerifyUserResponse & {
|
||||||
|
message?: string;
|
||||||
|
detail?: string;
|
||||||
|
mismatch_fields?: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (r.status < 200 || r.status >= 300) {
|
||||||
|
const msg = body?.message || body?.detail || "User verification failed";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: msg,
|
||||||
|
mismatch_fields: body?.mismatch_fields || null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, data: body } as const;
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: (err as Error).message } as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProfile() {
|
||||||
|
const response = await apiClient.get("/api/auth/profile/");
|
||||||
|
return handleApiResponse<User>(response, "getProfile");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionResult = { ok: boolean; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: ask the user to (re)upload their ID/passport photo. Non-destructive.
|
||||||
|
* `message` is the editable SMS body; the greeting, the secure upload link, and
|
||||||
|
* the signature are added by the backend (the link is never shown to admins).
|
||||||
|
*/
|
||||||
|
export async function requestIdUpload(
|
||||||
|
userId: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/api/auth/users/${userId}/request-id-card/`,
|
||||||
|
{ message },
|
||||||
|
);
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const error = (response.data ?? {}) as ApiError;
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error.message || error.detail || "Failed to request ID upload.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = (response.data ?? {}) as ApiError;
|
||||||
|
return { ok: true, message: data.message || "ID upload request sent." };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: reject the registration permanently — deletes the user and forces a
|
||||||
|
* fresh registration. A reason is required and is SMSed to the customer.
|
||||||
|
*/
|
||||||
|
export async function rejectAndDeleteUser(
|
||||||
|
userId: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const response = await apiClient.delete(`/api/auth/users/${userId}/reject/`, {
|
||||||
|
data: { rejection_details: reason },
|
||||||
|
});
|
||||||
|
if (response.status === 204) {
|
||||||
|
return { ok: true, message: "User rejected." };
|
||||||
|
}
|
||||||
|
const error = (response.data ?? {}) as ApiError;
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error.message || error.detail || "Failed to reject user.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserFormState = {
|
||||||
|
message: string;
|
||||||
|
fieldErrors?: {
|
||||||
|
id_card?: string[];
|
||||||
|
first_name?: string[];
|
||||||
|
last_name?: string[];
|
||||||
|
dob?: string[];
|
||||||
|
mobile?: string[];
|
||||||
|
address?: string[];
|
||||||
|
};
|
||||||
|
payload?: FormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
_prevState: UpdateUserFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<UpdateUserFormState> {
|
||||||
|
const userId = formData.get("userId") as string;
|
||||||
|
const data: Record<string, string | number | boolean> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (key !== "userId" && value !== undefined && value !== "") {
|
||||||
|
data[key] = typeof value === "number" ? value : String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/api/auth/users/${userId}/update/`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
const json = (response.data ?? {}) as Record<string, unknown> &
|
||||||
|
Partial<User> & { message?: string; detail?: string; field_errors?: unknown };
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const isFieldErrorObject =
|
||||||
|
json &&
|
||||||
|
typeof json === "object" &&
|
||||||
|
!Array.isArray(json) &&
|
||||||
|
Object.values(json).every(
|
||||||
|
(val) => Array.isArray(val) && val.every((v) => typeof v === "string"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
json.message ||
|
||||||
|
json.detail ||
|
||||||
|
(isFieldErrorObject
|
||||||
|
? "Please correct the highlighted fields."
|
||||||
|
: "An error occurred while updating the user."),
|
||||||
|
fieldErrors: isFieldErrorObject
|
||||||
|
? (json as UpdateUserFormState["fieldErrors"])
|
||||||
|
: (json.field_errors as UpdateUserFormState["fieldErrors"]) || {},
|
||||||
|
payload: formData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(json as unknown as User),
|
||||||
|
message: "User updated successfully",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserAgreement(
|
||||||
|
_prevState: UpdateUserFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<UpdateUserFormState> {
|
||||||
|
const userId = formData.get("userId") as string;
|
||||||
|
// Remove userId from formData before sending to API
|
||||||
|
const apiFormData = new FormData();
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (key !== "userId") {
|
||||||
|
apiFormData.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// axios sets the multipart boundary content-type automatically for FormData.
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/api/auth/users/${userId}/agreement/`,
|
||||||
|
apiFormData,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const errorData = (response.data ?? {}) as ApiError & {
|
||||||
|
field_errors?: UpdateUserFormState["fieldErrors"];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
errorData.message ||
|
||||||
|
errorData.detail ||
|
||||||
|
"An error occurred while updating the user agreement.",
|
||||||
|
fieldErrors: errorData.field_errors || {},
|
||||||
|
payload: formData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUserAgreement = (response.data ?? {}) as { agreement: string };
|
||||||
|
return {
|
||||||
|
...updatedUserAgreement,
|
||||||
|
message: "User agreement updated successfully",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddTopupFormState = {
|
||||||
|
status: boolean;
|
||||||
|
message: string;
|
||||||
|
fieldErrors?: {
|
||||||
|
amount?: string[];
|
||||||
|
description?: string[];
|
||||||
|
};
|
||||||
|
payload?: FormData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function adminUserTopup(
|
||||||
|
_prevState: AddTopupFormState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<AddTopupFormState> {
|
||||||
|
const user_id = formData.get("user_id") as string;
|
||||||
|
const amount = formData.get("amount") as string;
|
||||||
|
const description = formData.get("description") as string;
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
return {
|
||||||
|
status: false,
|
||||||
|
message: "Amount is required",
|
||||||
|
fieldErrors: { amount: ["Amount is required"], description: [] },
|
||||||
|
payload: formData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await apiClient.post("/api/billing/admin-topup/", {
|
||||||
|
amount: Number.parseInt(amount),
|
||||||
|
user_id: Number.parseInt(user_id),
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const errorData = (response.data ?? {}) as ApiError;
|
||||||
|
return {
|
||||||
|
status: false,
|
||||||
|
message:
|
||||||
|
errorData.message ||
|
||||||
|
errorData.detail ||
|
||||||
|
"An error occurred while topping up the user.",
|
||||||
|
fieldErrors: {},
|
||||||
|
payload: formData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: true,
|
||||||
|
message: "User topped up successfully",
|
||||||
|
fieldErrors: {},
|
||||||
|
payload: formData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,3 @@
|
|||||||
"use client";
|
|
||||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
"use client";
|
|
||||||
import { deviceCartAtom } from "@/lib/atoms";
|
import { deviceCartAtom } from "@/lib/atoms";
|
||||||
import type { Device } from "@/lib/backend-types";
|
import type { Device } from "@/lib/backend-types";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { HandCoins } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { isAdminUser, userAtom } from "@/lib/auth-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getDevices } from "@/queries/devices";
|
||||||
|
import BlockDeviceDialog from "../block-device-dialog";
|
||||||
|
import ClientErrorMessage from "../client-error-message";
|
||||||
|
import FullPageLoader from "../full-page-loader";
|
||||||
|
import Pagination from "../pagination";
|
||||||
|
|
||||||
|
export function AdminDevicesTable() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const isAdmin = isAdminUser(user);
|
||||||
|
|
||||||
|
const page = Number.parseInt(searchParams.get("page") as string) || 1;
|
||||||
|
const limit = 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const apiParams: Record<string, string | number | undefined> = {};
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
if (value !== undefined && value !== "") {
|
||||||
|
apiParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiParams.limit = limit;
|
||||||
|
apiParams.offset = offset;
|
||||||
|
|
||||||
|
const { data: devices, error, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin-devices", apiParams],
|
||||||
|
queryFn: () => getDevices(apiParams, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <FullPageLoader />;
|
||||||
|
if (error) return <ClientErrorMessage message={error.message} />;
|
||||||
|
if (!devices) return null;
|
||||||
|
|
||||||
|
const { meta, data } = devices;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.length === 0 ? (
|
||||||
|
<div className="h-[calc(100svh-400px)] text-muted-foreground flex flex-col items-center justify-center my-4">
|
||||||
|
<h3>No devices.</h3>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Table className="overflow-scroll">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Device Name</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>MAC Address</TableHead>
|
||||||
|
<TableHead>Vendor</TableHead>
|
||||||
|
<TableHead>#</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="overflow-scroll">
|
||||||
|
{data?.map((device) => (
|
||||||
|
<TableRow key={device.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"hover:underline font-semibold",
|
||||||
|
device.is_active ? "text-green-600" : "",
|
||||||
|
)}
|
||||||
|
to={`/devices/${device.id}`}
|
||||||
|
>
|
||||||
|
{device.name}
|
||||||
|
</Link>
|
||||||
|
{device.is_active ? (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Active until{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{new Date(
|
||||||
|
device.expiry_date || "",
|
||||||
|
).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Device Inactive
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{device.has_a_pending_payment && (
|
||||||
|
<Link to={`/payments/${device.pending_payment_id}`}>
|
||||||
|
<span className="bg-muted rounded px-2 p-1 mt-2 flex hover:underline items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
Payment Pending{" "}
|
||||||
|
<HandCoins className="animate-pulse" size={14} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{device.blocked_by === "ADMIN" && device.blocked && (
|
||||||
|
<div className="p-2 rounded border my-2 bg-white dark:bg-neutral-800 shadow">
|
||||||
|
<span className="font-semibold">Comment</span>
|
||||||
|
<p className="text-neutral-400">
|
||||||
|
{device?.reason_for_blocking}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
{device?.user?.name}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{device?.user?.id_card}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{device.mac}</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{device?.vendor}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!device.has_a_pending_payment && (
|
||||||
|
<BlockDeviceDialog
|
||||||
|
admin={isAdmin}
|
||||||
|
type={device.blocked ? "unblock" : "block"}
|
||||||
|
device={device}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-muted-foreground">
|
||||||
|
{meta?.total === 1 ? (
|
||||||
|
<p className="text-center">Total {meta?.total} device.</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-center">Total {meta?.total} devices.</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
totalPages={meta?.last_page}
|
||||||
|
currentPage={meta?.current_page}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2, PlusCircle } from "lucide-react";
|
import { Loader2, PlusCircle } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { useActionState, useEffect, useState } from "react"; // Import useActionState
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { adminUserTopup } from "@/actions/user-actions";
|
import { adminUserTopup } from "@/actions/user-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -37,7 +36,8 @@ export const initialState: AddTopupFormState = {
|
|||||||
|
|
||||||
export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
|
export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const router = useRouter();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [state, formAction, pending] = useActionState(
|
const [state, formAction, pending] = useActionState(
|
||||||
adminUserTopup,
|
adminUserTopup,
|
||||||
initialState,
|
initialState,
|
||||||
@@ -50,12 +50,13 @@ export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
|
|||||||
} else if (state.status) {
|
} else if (state.status) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
toast.success(state.message);
|
toast.success(state.message);
|
||||||
router.push("/user-topups?page=1");
|
queryClient.invalidateQueries({ queryKey: ["topups"] });
|
||||||
|
navigate("/user-topups?page=1");
|
||||||
} else {
|
} else {
|
||||||
toast.error(state.message);
|
toast.error(state.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state, router]);
|
}, [state, navigate, queryClient]);
|
||||||
|
|
||||||
if (!user_id) {
|
if (!user_id) {
|
||||||
return null;
|
return null;
|
||||||
@@ -104,7 +105,9 @@ export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
|
|||||||
<Label htmlFor="description">Topup Description</Label>
|
<Label htmlFor="description">Topup Description</Label>
|
||||||
<input type="hidden" name="user_id" value={user_id} />
|
<input type="hidden" name="user_id" value={user_id} />
|
||||||
<Textarea
|
<Textarea
|
||||||
defaultValue={(state?.payload?.get("description") || "") as string}
|
defaultValue={
|
||||||
|
(state?.payload?.get("description") || "") as string
|
||||||
|
}
|
||||||
rows={10}
|
rows={10}
|
||||||
name="description"
|
name="description"
|
||||||
id="topup_description"
|
id="topup_description"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { getTopups } from "@/actions/payment";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import ClientErrorMessage from "../client-error-message";
|
||||||
|
import FullPageLoader from "../full-page-loader";
|
||||||
|
import Pagination from "../pagination";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
export function AdminTopupsTable() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const page = Number.parseInt(searchParams.get("page") as string) || 1;
|
||||||
|
const limit = 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const apiParams: Record<string, string | number | undefined> = {};
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
if (value !== undefined && value !== "") {
|
||||||
|
apiParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiParams.limit = limit;
|
||||||
|
apiParams.offset = offset;
|
||||||
|
|
||||||
|
const { data: topups, error, isLoading } = useQuery({
|
||||||
|
queryKey: ["topups", apiParams],
|
||||||
|
queryFn: () => getTopups(apiParams, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <FullPageLoader />;
|
||||||
|
if (error) return <ClientErrorMessage message={error.message} />;
|
||||||
|
if (!topups) return null;
|
||||||
|
|
||||||
|
const { data, meta } = topups;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data?.length === 0 ? (
|
||||||
|
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
|
||||||
|
<h3>No topups yet.</h3>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Table className="overflow-scroll">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="overflow-scroll">
|
||||||
|
{topups?.data?.map((topup) => (
|
||||||
|
<TableRow key={topup.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
{topup?.user?.name}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{topup?.user?.id_card}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-semibold pr-2">
|
||||||
|
{topup.paid ? (
|
||||||
|
<Badge
|
||||||
|
className="bg-green-100 dark:bg-green-700"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{topup.status}
|
||||||
|
</Badge>
|
||||||
|
) : topup.is_expired ? (
|
||||||
|
<Badge>Expired</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">{topup.status}</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-semibold pr-2">
|
||||||
|
{topup.amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
MVR
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Link
|
||||||
|
className="font-medium hover:underline"
|
||||||
|
to={`/top-ups/${topup.id}`}
|
||||||
|
>
|
||||||
|
<Button size={"sm"} variant="outline">
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-muted-foreground">
|
||||||
|
{meta?.total === 1 ? (
|
||||||
|
<p className="text-center">Total {meta?.total} topup.</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-center">Total {meta?.total} topups.</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
totalPages={meta?.last_page}
|
||||||
|
currentPage={meta?.current_page}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { getPayments } from "@/actions/payment";
|
||||||
|
import Pagination from "@/components/pagination";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import ClientErrorMessage from "../client-error-message";
|
||||||
|
import FullPageLoader from "../full-page-loader";
|
||||||
|
|
||||||
|
export function UsersPaymentsTable() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const page = Number.parseInt(searchParams.get("page") as string) || 1;
|
||||||
|
const limit = 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const apiParams: Record<string, string | number | undefined> = {};
|
||||||
|
for (const [key, value] of searchParams.entries()) {
|
||||||
|
if (value !== undefined && value !== "") {
|
||||||
|
apiParams[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiParams.limit = limit;
|
||||||
|
apiParams.offset = offset;
|
||||||
|
|
||||||
|
const { data: payments, error, isLoading } = useQuery({
|
||||||
|
queryKey: ["user-payments", apiParams],
|
||||||
|
queryFn: () => getPayments(apiParams, true),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <FullPageLoader />;
|
||||||
|
if (error) return <ClientErrorMessage message={error.message} />;
|
||||||
|
if (!payments) return null;
|
||||||
|
|
||||||
|
const { meta, data } = payments;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
|
||||||
|
<h3>No user payments yet.</h3>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Table className="overflow-scroll">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Devices paid</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Duration</TableHead>
|
||||||
|
<TableHead>Payment Status</TableHead>
|
||||||
|
<TableHead>Payment Method</TableHead>
|
||||||
|
<TableHead>MIB Reference</TableHead>
|
||||||
|
<TableHead>Paid At</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="overflow-scroll">
|
||||||
|
{data.map((payment) => (
|
||||||
|
<TableRow
|
||||||
|
className={`${payment.paid && "title-bg dark:bg-black"}`}
|
||||||
|
key={payment.id}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<ol className="list-disc list-inside text-sm">
|
||||||
|
{payment.devices.map((device) => (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{device.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
{payment?.user?.name}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{payment?.user?.id_card}
|
||||||
|
</span>
|
||||||
|
</div>{" "}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{payment.amount} MVR</TableCell>
|
||||||
|
<TableCell>{payment.number_of_months} Months</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{payment.status === "PENDING" ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-yellow-100 text-black"
|
||||||
|
>
|
||||||
|
{payment.status}
|
||||||
|
</Badge>
|
||||||
|
) : payment.status === "PAID" ? (
|
||||||
|
<Badge variant="outline" className="bg-lime-100 text-black">
|
||||||
|
{payment.status}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-red-100 text-black">
|
||||||
|
{payment.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{payment.method}</TableCell>
|
||||||
|
<TableCell>{payment.mib_reference}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(payment.paid_at ?? "").toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Link to={`/payments/${payment.id}`}>
|
||||||
|
<Button>Details</Button>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-muted-foreground">
|
||||||
|
{meta?.total === 1 ? (
|
||||||
|
<p className="text-center">Total {meta?.total} payment.</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-center">Total {meta?.total} payments.</p>
|
||||||
|
)}{" "}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
<Pagination
|
||||||
|
totalPages={meta?.last_page}
|
||||||
|
currentPage={meta?.current_page}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { EyeIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function AgreementCard({ agreement }: { agreement: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sarlink User Agreement</CardTitle>
|
||||||
|
<CardDescription>User agreement for Sarlink services.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex-col gap-2">
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="w-full hover:cursor-pointer"
|
||||||
|
href={agreement}
|
||||||
|
>
|
||||||
|
<Button type="button" className="w-full hover:cursor-pointer">
|
||||||
|
<EyeIcon />
|
||||||
|
View Agreement
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||