29 Commits
Author SHA1 Message Date
shihaam b3ea1bf48f improve registration flows
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 7s
2026-08-04 01:11:33 +05:00
shihaam d536448d2b fix whole app crash when no rejection text
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 20s
2026-08-03 21:59:04 +05:00
shihaam d2fd218ee4 fix admin user related issues
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-03 21:44:57 +05:00
shihaam 5244caa37e fix white flashbang
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-03 20:21:05 +05:00
shihaam 46214c55fc fix session persist bug
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-03 19:06:21 +05:00
shihaam 7d94a15ca3 handle backend errors
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 10s
2026-08-03 18:30:52 +05:00
shihaam c6015e27a6 rewrite
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 33s
2026-08-02 19:58:39 +05:00
shihaam 292bb29099 :D
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 4s
2026-08-02 19:00:39 +05:00
shihaam d853d54a06 init vite rewrite
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 31s
2026-08-02 18:55:39 +05:00
shihaam 2f5659f3ff document the ui
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 8s
2026-08-02 18:44:46 +05:00
shihaam 68657c92ea handle payment erros better
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 10s
2026-08-02 15:12:01 +05:00
shihaam 3c1682cec3 /profile dont look like i can edit them anymore
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 6s
2026-08-02 14:58:07 +05:00
shihaam c137ef0847 code clean up, remove frontend sms api??? why was it there at all?? and some cron things
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 4s
2026-08-02 06:10:25 +05:00
shihaam 446a501053 update env example
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
2026-08-02 06:04:41 +05:00
shihaam 47da19186c update env example
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 9s
2026-08-02 05:58:44 +05:00
shihaam 4157c53a86 update compose for monorepo
Build and Push Docker Images / Build and Push Docker Images (push) Failing after 5s
2026-08-02 05:10:46 +05:00
i701 f0cf2d5223 fix: prevent profile page from crashing for null profile values 🔨 2025-09-24 20:04:04 +05:00
i701 9ad1887f88 refactor: add animations 2025-09-24 19:33:48 +05:00
i701 f8774f51e6 style: add skeletons to paymentId and deviceId pages ♻️ 2025-09-24 18:20:35 +05:00
i701 31a05ae917 style: add skeletons to paymentId and deviceId pages ♻️ 2025-09-24 17:46:04 +05:00
i701 5dab74b14b refactor: create utility function to hide AccountInformation component for topup and payment 🔧 2025-09-21 10:07:16 +05:00
i701 a60e9a9c85 chore: add skeletons to tables and loading.tsx files for routes and run formatting ♻️ 2025-09-20 20:42:14 +05:00
i701 5277c13fb7 fix: allow admins only to block with details in parental control page (mobile view) 🐛 2025-09-20 19:07:08 +05:00
i701 035cd02012 fix: payment info display logic condition 🐛 2025-09-20 14:57:21 +05:00
i701 dc10fa6be4 chore: release v0.2.2 2025-09-20 14:41:32 +05:00
i701 39e84723b1 refactor: remove table captions from all tables 🔧 2025-09-20 14:40:55 +05:00
i701 19043aa692 fix: set 100 MVR for default wallet topup amount 🔧 2025-09-20 14:25:48 +05:00
i701 f2a17d522b fix: hide account information once the payment is verified/expired/cancelled 🔧 2025-09-20 14:17:48 +05:00
i701 43b8e22196 fix: remove pagination if there is only 1 page 🔧 2025-09-20 14:09:12 +05:00
244 changed files with 7676 additions and 16058 deletions
+5
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
FROM node:18
WORKDIR /var/www/html
CMD npm run dev
-5
View File
@@ -1,5 +0,0 @@
FROM node:18
WORKDIR /var/www/html
CMD npx prisma studio
-12
View File
@@ -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
```
-23
View File
@@ -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"]
-7
View File
@@ -1,7 +0,0 @@
services:
app:
build:
context: ../../
dockerfile: .build/prod/bun.Dockerfile
hostname: sarlink-portal
image: git.shihaam.dev/sarlink/sarlink-portal
-7
View File
@@ -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
+4 -23
View File
@@ -1,23 +1,4 @@
# next auth
NEXTAUTH_SECRET=""
NEXTAUTH_URL=""
## Portal API
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=""
# API base for the browser → Django.
# Dev: http://localhost:8000/ (docker compose maps host :8000 → backend:5000)
# Prod: leave empty for same-origin (nginx proxies /api, /callback, /auth to Django)
VITE_API_URL=http://localhost:8000/
-6
View File
@@ -1,6 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "error"
}
}
-19
View File
@@ -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
+20 -41
View File
@@ -1,46 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/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
# Logs
logs
*.log
npm-debug.log*
yarn-debug.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.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
#sqlite
*.db
-1
View File
@@ -1 +0,0 @@
npx commitlint --edit $1
-1
View File
@@ -1 +0,0 @@
npm run lint
+8
View File
@@ -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 }]
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
-234
View File
@@ -1,234 +0,0 @@
# Changelog
## <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))
+23 -38
View File
@@ -1,47 +1,32 @@
This is a web portal for SAR Link customers.
# Todos
## Layout
- [ ] Auto hide the welcome banner after 5 seconds
# React + TypeScript + Vite
## User Menu
### 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
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
### Payments
- [x] Show payments table
- [x] Add all the filters for payment table (mobile responsive)
- [x] add slider range filter
- [ ] Fix bill formula linking for generated payments
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
### Parental Control
- [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
## React Compiler
### Topups
- [ ] add textarea in topup dialog for admin. if no detail given generate on backend otherwise take the text from text area
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).
### Agreements
- [x] Implement file upload for admin side
- [ ] Add customer relavant documents in a grid view
## Expanding the Oxlint configuration
## Admin Controls
### 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
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
### User Devices
- [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
- [x] Show the devices table
- [x] Add all relevant filters for user devices table
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
### User Payments
- [x] Show user payments table
- [x] Add relevant filters for user payments table
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
-229
View File
@@ -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");
}
-68
View File
@@ -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;
}
-159
View File
@@ -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");
}
}
-383
View File
@@ -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: {},
};
}
}
}
-305
View File
@@ -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,
};
}
-16
View File
@@ -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} />;
}
-19
View File
@@ -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} />;
}
-29
View File
@@ -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,78 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCaption,
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">
<TableCaption>Table of all devices.</TableCaption>
<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>
);
}
-14
View File
@@ -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 />;
}
-95
View File
@@ -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,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>
);
}
-63
View File
@@ -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>
);
}
-5
View File
@@ -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 };
-146
View File
@@ -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;
//
}
-106
View File
@@ -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,
};
Binary file not shown.
Binary file not shown.
-58
View File
@@ -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>
);
}
-34
View File
@@ -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;
}
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default async function Home() {
return redirect("/devices");
}
-37
View File
@@ -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"
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
export default {
extends: ['@commitlint/config-conventional']
};
-21
View File
@@ -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"
}
-30
View File
@@ -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>
)
}
-104
View File
@@ -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>
);
}
-286
View File
@@ -1,286 +0,0 @@
import { Calendar } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getPayments } from "@/actions/payment";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Payment } from "@/lib/backend-types";
import { cn } from "@/lib/utils";
import { tryCatch } from "@/utils/tryCatch";
import Pagination from "./pagination";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Separator } from "./ui/separator";
export async function PaymentsTable({
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, payments] = await tryCatch(getPayments(apiParams));
if (error) {
if (error.message.includes("Unauthorized")) {
redirect("/auth/signin");
} else {
return <pre>{JSON.stringify(error, null, 2)}</pre>;
}
}
const { data, meta } = payments;
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 Payments.</h3>
</div>
) : (
<>
<div className="hidden sm:block">
<Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Details</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Status</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{payments?.data?.map((payment) => (
<TableRow key={payment.id}>
<TableCell>
<div
className={cn(
"flex flex-col items-start border rounded p-2",
payment?.paid
? "bg-green-500/10 border-dashed border-green-500"
: payment?.is_expired
? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)}
>
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground">
{new Date(payment.created_at).toLocaleDateString(
"en-US",
{
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
timeZone: "Indian/Maldives", // Force consistent timezone
},
)}
</span>
</div>
<div className="flex items-center gap-2 mt-2">
<Link
className="font-medium hover:underline"
href={`/payments/${payment.id}`}
>
<Button size={"sm"} variant="outline">
View Details
</Button>
</Link>
</div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border">
<h3 className="text-sm font-medium">Devices</h3>
<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>
</div>
</div>
</TableCell>
<TableCell className="font-medium">
{payment.number_of_months} Months
</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{payment.paid ? (
<Badge
className={cn(
payment.status === "PENDING"
? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100"
: "bg-green-100 dark:bg-green-700",
)}
variant="outline"
>
{payment.status}
</Badge>
) : payment.is_expired ? (
<Badge>Expired</Badge>
) : (
<Badge variant="outline">{payment.status}</Badge>
)}
</span>
</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{payment.amount.toFixed(2)}
</span>
MVR
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={4} 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>
<div className="sm:hidden block">
{data.map((payment) => (
<MobilePaymentDetails key={payment.id} payment={payment} />
))}
</div>
</>
)}
</div>
);
}
export function MobilePaymentDetails({
payment,
isAdmin = false,
}: {
payment: Payment;
isAdmin?: boolean;
}) {
return (
<div
className={cn(
"flex flex-col items-start border rounded p-2 my-2",
payment?.paid
? "bg-green-500/10 border-dashed border-green-500"
: payment?.is_expired
? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)}
>
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm">
{new Date(payment.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
timeZone: "Indian/Maldives", // Force consistent timezone
})}
</span>
</div>
<div className="flex items-center gap-2 mt-2">
<Link
className="font-medium hover:underline"
href={`/payments/${payment.id}`}
>
<Button size={"sm"} variant="outline">
View Details
</Button>
</Link>
</div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border">
<h3 className="text-sm font-medium">Devices</h3>
<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>
<div className="block sm:hidden">
<Separator className="my-2" />
<h3 className="text-sm font-medium">Duration</h3>
<span className="text-sm text-muted-foreground">
{payment.number_of_months} Months
</span>
<Separator className="my-2" />
<h3 className="text-sm font-medium">Amount</h3>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{payment.amount.toFixed(2)} MVR
</span>
<span className="font-semibold pr-2">
{payment.paid ? (
<Badge
className={cn(
payment.status === "PENDING"
? "bg-yellow-100 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-100"
: "bg-green-100 dark:bg-green-700",
)}
variant="outline"
>
{payment.status}
</Badge>
) : payment.is_expired ? (
<Badge>Expired</Badge>
) : (
<Badge variant="secondary">{payment.status}</Badge>
)}
</span>
{isAdmin && (
<div className="my-2 text-primary flex flex-col items-start text-sm border rounded p-2 mt-2 w-full bg-white dark:bg-black">
{payment?.user?.name}
<span className="text-muted-foreground">
{payment?.user?.id_card}
</span>
</div>
)}
</div>
</div>
</div>
</div>
);
}
-153
View File
@@ -1,153 +0,0 @@
"use client";
import { BadgeDollarSign, Loader2 } from "lucide-react";
import { useActionState, useEffect } from "react";
import { toast } from "sonner";
import {
type VerifyTopupPaymentState,
verifyTopupPayment,
} from "@/actions/payment";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableRow,
} from "@/components/ui/table";
import type { Topup } from "@/lib/backend-types";
import { AccountInfomation } from "./account-information";
import { Button } from "./ui/button";
const initialState: VerifyTopupPaymentState = {
message: "",
success: false,
fieldErrors: {},
};
export default function TopupToPay({
topup,
disabled,
}: {
topup?: Topup;
disabled?: boolean;
}) {
const [state, formAction, isPending] = useActionState(
verifyTopupPayment,
initialState,
);
// Handle toast notifications based on state changes
useEffect(() => {
if (state.success && state.message) {
toast.success("Topup successful!", {
closeButton: true,
description: state.transaction
? `Your topup payment has been verified successfully using ${state.transaction.sourceBank} bank transfer on ${state.transaction.trxDate}.`
: state.message,
});
} else if (
!state.success &&
state.message &&
state.message !== initialState.message
) {
toast.error("Topup Payment Verification Failed", {
closeButton: true,
description: state.message,
});
}
}, [state]);
return (
<div className="w-full">
<div className="m-2 flex items-end justify-end p-2 text-sm text-foreground border rounded">
<Table>
<TableCaption>
<div className="max-w-sm mx-auto">
<p>Please send the following amount to the payment address</p>
<AccountInfomation
accName="Baraveli Dev"
accountNo="90101400028321000"
/>
{topup?.paid ? (
<Button
size={"lg"}
variant={"secondary"}
disabled
className="dark:text-green-200 text-green-900 bg-green-500/20 uppercase font-semibold"
>
Topup Payment Verified
</Button>
) : (
<div className="flex flex-col gap-2">
<form action={formAction}>
<input
type="hidden"
name="topupId"
value={topup?.id ?? ""}
/>
<Button
disabled={disabled || isPending}
type="submit"
size={"lg"}
className="mb-4 w-full"
>
{isPending ? "Processing payment..." : "I have paid"}
{isPending ? (
<Loader2 className="animate-spin" />
) : (
<BadgeDollarSign />
)}
</Button>
</form>
</div>
)}
</div>
</TableCaption>
<TableBody className="">
<TableRow>
<TableCell>Topup created</TableCell>
<TableCell className="text-right text-muted-foreground">
{new Date(topup?.created_at ?? "").toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
second: "2-digit",
})}
</TableCell>
</TableRow>
<TableRow>
<TableCell>Payment received</TableCell>
<TableCell className="text-right text-sarLinkOrange">
{topup?.paid_at
? new Date(topup.paid_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
second: "2-digit",
})
: "-"}
</TableCell>
</TableRow>
<TableRow>
<TableCell>MIB Reference</TableCell>
<TableCell className="text-right">
{topup?.mib_reference ? topup.mib_reference : "-"}
</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow className="">
<TableCell colSpan={1}>Total Due</TableCell>
<TableCell className="text-right text-3xl font-bold">
{topup?.amount?.toFixed(2)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
);
}
-219
View File
@@ -1,219 +0,0 @@
import { Calendar } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTopups } from "@/actions/payment";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { Topup } from "@/lib/backend-types";
import { cn } from "@/lib/utils";
import { tryCatch } from "@/utils/tryCatch";
import Pagination from "./pagination";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
export async function TopupsTable({
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));
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 text-muted-foreground flex-col items-center justify-center my-4">
<h3>No topups.</h3>
</div>
) : (
<>
<div className="hidden sm:block">
<Table className="overflow-scroll">
<TableCaption>Table of all topups.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{topups?.data?.map((topup) => (
<TableRow key={topup.id}>
<TableCell>
<div
className={cn(
"flex flex-col items-start border rounded p-2",
topup?.paid
? "bg-green-500/10 border-dashed border-green-500"
: topup?.is_expired
? "bg-gray-500/10 border-dashed border-gray-500 dark:border-gray-500/50"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)}
>
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground">
{new Date(topup.created_at).toLocaleDateString(
"en-US",
{
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
},
)}
</span>
</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>
<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>
</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>
<div className="sm:hidden block">
{data.map((topup) => (
<MobileTopupDetails key={topup.id} topup={topup} />
))}
</div>
<Pagination
totalPages={meta?.last_page}
currentPage={meta?.current_page}
/>
</>
)}
</div>
);
}
function MobileTopupDetails({ topup }: { topup: Topup }) {
return (
<div
className={cn(
"flex flex-col items-start border rounded p-2 my-2",
topup?.paid
? "bg-green-500/10 border-dashed border-green=500"
: "bg-yellow-500/10 border-dashed border-yellow-500 dark:border-yellow-500/50",
)}
>
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm">
{new Date(topup.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
</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 className="bg-white dark:bg-black p-2 rounded mt-2 w-full border flex justify-between items-center">
<div className="block sm:hidden">
<h3 className="text-sm font-medium">Amount</h3>
<span className="text-sm text-muted-foreground">
{topup.amount.toFixed(2)} MVR
</span>
</div>
<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="secondary">{topup.status}</Badge>
)}
</span>
</div>
</div>
);
}
-121
View File
@@ -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>
);
}
-243
View File
@@ -1,243 +0,0 @@
import { Calendar } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { WalletTransaction } from "@/lib/backend-types";
import { cn } from "@/lib/utils";
import { getWaleltTransactions } from "@/queries/wallet";
import { tryCatch } from "@/utils/tryCatch";
import Pagination from "./pagination";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
export async function WalletTransactionsTable({
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, transactions] = await tryCatch(
getWaleltTransactions(apiParams),
);
if (error) {
if (error.message.includes("Unauthorized")) {
redirect("/auth/signin");
} else {
return <pre>{JSON.stringify(error, null, 2)}</pre>;
}
}
const { data, meta } = transactions;
const totalDebit = data.reduce(
(acc, trx) => acc + (trx.transaction_type === "DEBIT" ? trx.amount : 0),
0,
);
const totalCredit = data.reduce(
(acc, trx) => acc + (trx.transaction_type === "TOPUP" ? trx.amount : 0),
0,
);
return (
<div>
{data?.length === 0 ? (
<div className="h-[calc(100svh-400px)] flex flex-col items-center justify-center my-4">
<h3>No transactions yet.</h3>
</div>
) : (
<div>
<div className="flex gap-4 mb-4 w-full">
<div className="bg-red-400 w-full sm:w-fit dark:bg-red-950 dark:text-red-400 text-red-900 p-2 px-4 rounded-md mb-2">
<h5 className="text-lg font-semibold">Total Debit</h5>
<p>{totalDebit.toFixed(2)} MVR</p>
</div>
<div className="bg-green-400 w-full sm:w-fit dark:bg-green-950 dark:text-green-400 text-green-900 p-2 px-4 rounded-md mb-2">
<h5 className="text-lg font-semibold">Total Credit</h5>
<p>{totalCredit.toFixed(2)} MVR</p>
</div>
</div>
<div className="hidden sm:block">
<Table className="overflow-scroll">
<TableCaption>Table of all transactions.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Description</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Transaction Type</TableHead>
<TableHead>View Details</TableHead>
<TableHead>Created at</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{transactions?.data?.map((trx) => (
<TableRow
className={cn(
"items-start border rounded p-2",
trx?.transaction_type === "TOPUP"
? "credit-bg"
: "debit-bg",
)}
key={trx.id}
>
<TableCell>
<span className="text-muted-foreground">
{trx.description}
</span>
</TableCell>
<TableCell>{trx.amount.toFixed(2)} MVR</TableCell>
<TableCell>
<span className="font-semibold pr-2">
{trx.transaction_type === "TOPUP" ? (
<Badge className="bg-green-100 text-green-950 dark:bg-green-700">
{trx.transaction_type}
</Badge>
) : (
<Badge className="bg-red-500 text-red-950 dark:bg-red-700">
{trx.transaction_type}
</Badge>
)}
</span>
</TableCell>
<TableCell>
<span className="">
{new Date(trx.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
})}
</span>
</TableCell>
<TableCell>
<Button>
<Link
className="font-medium "
href={
trx.transaction_type === "TOPUP"
? `/top-ups/${trx.reference_id}`
: `/payments/${trx.reference_id}`
}
>
View Details
</Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{meta?.total === 1 ? (
<p className="text-center">
Total {meta?.total} transaction.
</p>
) : (
<p className="text-center">
Total {meta?.total} transactions.
</p>
)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<div className="sm:hidden block">
{data.map((trx) => (
<MobileTransactionDetails key={trx.id} trx={trx} />
))}
</div>
<Pagination
totalPages={meta?.last_page}
currentPage={meta?.current_page}
/>
</div>
)}
</div>
);
}
function MobileTransactionDetails({ trx }: { trx: WalletTransaction }) {
return (
<div
className={cn(
"flex flex-col items-start border rounded p-2 my-2",
trx?.transaction_type === "TOPUP" ? "credit-bg" : "debit-bg",
)}
>
<div className="bg-white shadow dark:bg-black p-2 rounded w-full">
<div className="flex items-center gap-2">
<Calendar size={16} opacity={0.5} />
<span className="text-muted-foreground text-sm">
{new Date(trx.created_at).toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
minute: "2-digit",
hour: "2-digit",
})}
</span>
</div>
<p className="text-sm text-muted-foreground py-4">{trx.description}</p>
</div>
<div className="bg-white dark:bg-black p-2 rounded mt-2 w-full border flex justify-between items-center">
<div className="block sm:hidden">
<h3 className="text-sm font-medium">Amount</h3>
<span className="text-sm text-muted-foreground">
{trx.amount.toFixed(2)} MVR
</span>
</div>
<span className="font-semibold pr-2">
{trx.transaction_type === "TOPUP" ? (
<Badge className="bg-green-100 text-green-950 dark:bg-green-700">
{trx.transaction_type}
</Badge>
) : (
<Badge className="bg-red-500 text-red-950 dark:bg-red-700">
{trx.transaction_type}
</Badge>
)}
</span>
</div>
<div className="flex items-center gap-2 mt-2 w-full">
<Link
className="font-medium hover:underline"
href={
trx.transaction_type === "TOPUP"
? `/top-ups/${trx.reference_id}`
: `/payments/${trx.reference_id}`
}
>
<Button size={"sm"} className="w-full">
View Details
</Button>
</Link>
</div>
</div>
);
}
+5 -26
View File
@@ -1,32 +1,11 @@
services:
node:
frontend:
build:
context: .build/dev
dockerfile: node.Dockerfile
hostname: sarlink-portal-dev
dockerfile: Dockerfile
volumes:
- ./:/var/www/html/
ports:
- 3000:3000
prisma-studio:
build:
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:
- 5173:5173
env_file:
- .env
+46
View File
@@ -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.
+119
View File
@@ -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).
+86
View File
@@ -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.
+93
View File
@@ -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)).
+90
View File
@@ -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.
+55
View File
@@ -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.
+84
View File
@@ -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.
+183
View File
@@ -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.
+387
View File
@@ -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, 112). 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/`.
+40
View File
@@ -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>
-19
View File
@@ -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;
}
-72
View File
@@ -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;
}
-63
View File
@@ -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;
}
-48
View File
@@ -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");
}
}
-10
View File
@@ -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*"],
};
-34
View File
@@ -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;
+2337 -10678
View File
File diff suppressed because it is too large Load Diff
+20 -84
View File
@@ -1,54 +1,43 @@
{
"name": "sarlink-portal",
"version": "0.2.1",
"type": "module",
"name": "sarlink-portal-webui",
"private": true,
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prepare": "husky",
"release": "release-it"
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@faker-js/faker": "^9.3.0",
"@hookform/resolvers": "^5.1.1",
"@pyncz/tailwind-mask-image": "^2.0.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@tailwindcss/postcss": "^4.1.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-slider": "^1.3.5",
"@tanstack/react-query": "^5.61.4",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"commitlint": "^19.8.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"husky": "^9.1.7",
"input-otp": "^1.4.2",
"jotai": "2.8.0",
"lucide-react": "^0.523.0",
"moment": "^2.30.1",
"motion": "^12.15.0",
"next": "15.3.3",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"nextjs-toploader": "^3.7.15",
"nuqs": "^2.4.3",
"radix-ui": "^1.4.2",
"react": "19.1.0",
"react": "^19.2.8",
"react-aria-components": "^1.5.0",
"react-day-picker": "^9.7.0",
"react-dom": "19.1.0",
"react-dom": "^19.2.8",
"react-hook-form": "^7.58.1",
"react-phone-number-input": "^3.4.9",
"react-resizable-panels": "^3.0.3",
"react-router-dom": "^7.1.1",
"recharts": "^3.0.0",
"release-it": "^19.0.5",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
@@ -56,69 +45,16 @@
"zod": "^3.25.67"
},
"devDependencies": {
"@biomejs/biome": "2.0.6",
"@release-it/conventional-changelog": "^10.0.1",
"@types/node": "^22.10.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
"@typescript-eslint/eslint-plugin": "^8.35.0",
"@typescript-eslint/parser": "^8.35.0",
"eslint": "^9.29.0",
"eslint-config-next": "15.1.2",
"postcss": "^8.5.6",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"tailwindcss": "^4.1.11",
"tailwindcss-motion": "^1.1.0",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3"
},
"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"
}
]
}
}
}
"typescript": "~6.0.2",
"vite": "^8.2.0"
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-13
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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

-201
View File
@@ -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",
};
}
-47
View File
@@ -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;
}
-49
View File
@@ -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");
}
-48
View File
@@ -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;
}
+220
View File
@@ -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,
)}`,
};
}
+199
View File
@@ -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: {},
};
}
}
+244
View File
@@ -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,
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -1,4 +1,3 @@
"use client";
import { Clipboard, ClipboardCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
@@ -1,4 +1,3 @@
"use client";
import { deviceCartAtom } from "@/lib/atoms";
import type { Device } from "@/lib/backend-types";
import { useAtomValue, useSetAtom } from "jotai";
@@ -1,58 +1,51 @@
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 { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Link, useSearchParams } from "react-router-dom";
import {
Table,
TableBody,
TableCaption,
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 { tryCatch } from "@/utils/tryCatch";
import BlockDeviceDialog from "../block-device-dialog";
import ClientErrorMessage from "../client-error-message";
import FullPageLoader from "../full-page-loader";
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;
export function AdminDevicesTable() {
const [searchParams] = useSearchParams();
const user = useAtomValue(userAtom);
const isAdmin = isAdminUser(user);
const page = Number.parseInt(resolvedParams.page as string) || 1;
const page = Number.parseInt(searchParams.get("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)) {
for (const [key, value] of searchParams.entries()) {
if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value);
apiParams[key] = 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 { 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>
@@ -64,7 +57,6 @@ export async function AdminDevicesTable({
<>
<div>
<Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Device Name</TableHead>
@@ -84,7 +76,7 @@ export async function AdminDevicesTable({
"hover:underline font-semibold",
device.is_active ? "text-green-600" : "",
)}
href={`/devices/${device.id}`}
to={`/devices/${device.id}`}
>
{device.name}
</Link>
@@ -107,7 +99,7 @@ export async function AdminDevicesTable({
</p>
)}
{device.has_a_pending_payment && (
<Link href={`/payments/${device.pending_payment_id}`}>
<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} />
@@ -155,9 +147,7 @@ export async function AdminDevicesTable({
{meta?.total === 1 ? (
<p className="text-center">Total {meta?.total} device.</p>
) : (
<p className="text-center">
Total {meta?.total} devices.
</p>
<p className="text-center">Total {meta?.total} devices.</p>
)}
</TableCell>
</TableRow>
@@ -1,8 +1,7 @@
"use client";
import { Loader2, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react"; // Import useActionState
import { useActionState, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { adminUserTopup } from "@/actions/user-actions";
import { Button } from "@/components/ui/button";
@@ -37,7 +36,8 @@ export const initialState: AddTopupFormState = {
export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
const [open, setOpen] = useState(false);
const router = useRouter();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [state, formAction, pending] = useActionState(
adminUserTopup,
initialState,
@@ -50,12 +50,13 @@ export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
} else if (state.status) {
setOpen(false);
toast.success(state.message);
router.push("/user-topups?page=1");
queryClient.invalidateQueries({ queryKey: ["topups"] });
navigate("/user-topups?page=1");
} else {
toast.error(state.message);
}
}
}, [state, router]);
}, [state, navigate, queryClient]);
if (!user_id) {
return null;
@@ -104,7 +105,9 @@ export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
<Label htmlFor="description">Topup Description</Label>
<input type="hidden" name="user_id" value={user_id} />
<Textarea
defaultValue={(state?.payload?.get("description") || "") as string}
defaultValue={
(state?.payload?.get("description") || "") as string
}
rows={10}
name="description"
id="topup_description"
@@ -1,50 +1,44 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { getTopups } from "@/actions/payment";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { tryCatch } from "@/utils/tryCatch";
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 async function AdminTopupsTable({
searchParams,
}: {
searchParams: Promise<{
[key: string]: unknown;
}>;
}) {
const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1;
export function AdminTopupsTable() {
const [searchParams] = useSearchParams();
const page = Number.parseInt(searchParams.get("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)) {
for (const [key, value] of searchParams.entries()) {
if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value);
apiParams[key] = 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: 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>
@@ -56,7 +50,6 @@ export async function AdminTopupsTable({
<>
<div>
<Table className="overflow-scroll">
<TableCaption>Table of all topups.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
@@ -103,7 +96,7 @@ export async function AdminTopupsTable({
<div className="flex items-center gap-2 mt-2">
<Link
className="font-medium hover:underline"
href={`/top-ups/${topup.id}`}
to={`/top-ups/${topup.id}`}
>
<Button size={"sm"} variant="outline">
View Details
@@ -1,5 +1,5 @@
import Link from "next/link";
import { redirect } from "next/navigation";
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";
@@ -7,50 +7,42 @@ import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { tryCatch } from "@/utils/tryCatch";
import ClientErrorMessage from "../client-error-message";
import FullPageLoader from "../full-page-loader";
export async function UsersPaymentsTable({
searchParams,
}: {
searchParams: Promise<{
[key: string]: unknown;
}>;
}) {
const resolvedParams = await searchParams;
export function UsersPaymentsTable() {
const [searchParams] = useSearchParams();
const page = Number.parseInt(resolvedParams.page as string) || 1;
const page = Number.parseInt(searchParams.get("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)) {
for (const [key, value] of searchParams.entries()) {
if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value);
apiParams[key] = 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 { 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 <pre>{JSON.stringify(payments, null, 2)}</pre>;
return (
<div>
{data.length === 0 ? (
@@ -60,7 +52,6 @@ export async function UsersPaymentsTable({
) : (
<>
<Table className="overflow-scroll">
<TableCaption>Table of all users.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Devices paid</TableHead>
@@ -93,7 +84,6 @@ export async function UsersPaymentsTable({
</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">
@@ -113,17 +103,11 @@ export async function UsersPaymentsTable({
{payment.status}
</Badge>
) : payment.status === "PAID" ? (
<Badge
variant="outline"
className="bg-lime-100 text-black"
>
<Badge variant="outline" className="bg-lime-100 text-black">
{payment.status}
</Badge>
) : (
<Badge
variant="outline"
className="bg-red-100 text-black"
>
<Badge variant="outline" className="bg-red-100 text-black">
{payment.status}
</Badge>
)}
@@ -144,7 +128,7 @@ export async function UsersPaymentsTable({
</TableCell>
<TableCell>
<Link href={`/payments/${payment.id}`}>
<Link to={`/payments/${payment.id}`}>
<Button>Details</Button>
</Link>
</TableCell>
+33
View File
@@ -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>
);
}
@@ -1,24 +1,22 @@
"use client";
import { useAtomValue } from "jotai";
import { Loader2, User as UserIcon } from "lucide-react";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { clearAuth, userAtom } from "@/lib/auth-store";
import { logout } from "@/queries/authentication";
import { tryCatch } from "@/utils/tryCatch";
export function AccountPopover() {
const session = useSession();
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
if (session.status === "loading") {
<Button variant={"outline"} disabled>
<Loader2 className="animate-spin" />
</Button>;
}
return (
<Popover>
<PopoverTrigger asChild>
@@ -30,11 +28,11 @@ export function AccountPopover() {
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">
{session.data?.user?.first_name} {session.data?.user?.last_name}
{user?.first_name} {user?.last_name}
</h4>
<div className="text-sm text-muted-foreground">
<p className="font-semibold">{session.data?.user?.id_card}</p>
<p>{session.data?.user?.mobile}</p>
<p className="font-semibold">{user?.id_card}</p>
<p>{user?.mobile}</p>
</div>
</div>
<div className="flex flex-col gap-2">
@@ -42,13 +40,16 @@ export function AccountPopover() {
disabled={loading}
onClick={async () => {
setLoading(true);
await signOut();
// Best-effort backend logout, then clear local auth.
await tryCatch(logout());
clearAuth();
setLoading(false);
navigate("/auth/signin");
}}
>
{loading ? <Loader2 className="animate-spin" /> : "Logout"}
</Button>
<Link href="/profile" className="text-muted-foreground">
<Link to="/profile" className="text-muted-foreground">
<Button variant={"secondary"} className="w-full">
View Profile
</Button>
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { useAtomValue } from "jotai";
import { useQuery } from "@tanstack/react-query";
import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
import { Outlet } from "react-router-dom";
import { getProfile } from "@/actions/user-actions";
import { authOptions } from "@/app/auth";
import { DeviceCartDrawer } from "@/components/device-cart";
import { ModeToggle } from "@/components/theme-toggle";
import { AppSidebar } from "@/components/ui/app-sidebar";
@@ -13,23 +13,22 @@ import {
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Wallet } from "@/components/wallet";
import { tryCatch } from "@/utils/tryCatch";
import { userAtom } from "@/lib/auth-store";
import { WelcomeBanner } from "../welcome-banner";
import { AccountPopover } from "./account-popver";
export async function ApplicationLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
export function ApplicationLayout() {
const user = useAtomValue(userAtom);
// Fresh profile (wallet balance) — replaces the old server-side getProfile.
// Falls back to the login snapshot in userAtom while loading.
const { data: profile } = useQuery({
queryKey: ["profile"],
queryFn: getProfile,
});
const walletBalance = profile?.wallet_balance ?? user?.wallet_balance ?? 0;
if (!session) return redirect("/auth/signin");
const [userError, userProfile] = await tryCatch(getProfile());
if (userError) {
if (userError.message === "Invalid token.") redirect("/auth/signin");
return null;
}
return (
<SidebarProvider>
<AppSidebar />
@@ -41,18 +40,20 @@ export async function ApplicationLayout({
</div>
<div className="flex items-center gap-2">
<Wallet walletBalance={userProfile?.wallet_balance || 0} />
<Wallet walletBalance={walletBalance} />
<ModeToggle />
<AccountPopover />
</div>
</header>
<WelcomeBanner
firstName={session?.user?.first_name}
lastName={session?.user?.last_name}
firstName={user?.first_name}
lastName={user?.last_name}
/>
<DeviceCartDrawer />
<div className="p-4 flex flex-col flex-1 rounded-lg bg-background">
<NuqsAdapter>{children}</NuqsAdapter>
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
</div>
</SidebarInset>
</SidebarProvider>
@@ -1,11 +1,9 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
import { Outlet } from "react-router-dom";
export function AuthLayout() {
return (
<div className="bg-gray-100 dark:bg-black w-full h-screen flex items-center justify-center font-sans">
{children}
<Outlet />
</div>
);
}
@@ -1,18 +1,24 @@
"use client";
import { Button } from "@/components/ui/button";
import { signin } from "@/actions/auth-actions";
import { Loader2 } from "lucide-react";
import { useActionState } from "react";
import { useActionState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { PhoneInput } from "../ui/phone-input";
export default function LoginForm() {
const navigate = useNavigate();
const [state, formAction, isPending] = useActionState(signin, {
message: "",
status: "",
});
useEffect(() => {
if (state.status === "redirect" && state.redirectTo) {
navigate(state.redirectTo);
}
}, [state, navigate]);
return (
<form
className="overflow-clip title-bg w-full max-w-xs mx-auto rounded-lg shadow border-2 border-sarLinkOrange/50 dark:border-sarLinkOrange/50 mt-4"
+24
View File
@@ -0,0 +1,24 @@
import { useAtomValue } from "jotai";
import { Navigate, useLocation } from "react-router-dom";
import { isAuthenticated, tokenAtom } from "@/lib/auth-store";
/**
* Client-side auth gate for the dashboard (replaces the old next-auth
* middleware). Renders its children only when authenticated; otherwise
* redirects to signin, preserving the attempted path as `callbackUrl`.
*
* Subscribes to `tokenAtom` so a logout / 401 clear re-evaluates the guard.
*/
export function RouteGuard({ children }: { children: React.ReactNode }) {
useAtomValue(tokenAtom);
const location = useLocation();
if (!isAuthenticated()) {
const callbackUrl = encodeURIComponent(
location.pathname + location.search,
);
return <Navigate to={`/auth/signin?callbackUrl=${callbackUrl}`} replace />;
}
return <>{children}</>;
}

Some files were not shown because too many files have changed in this diff Show More