31 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
i701 c041c2e7d7 chore: release v0.2.1 2025-09-20 13:39:13 +05:00
i701 e5298aa323 fix: add release script 🔧 2025-09-20 13:37:35 +05:00
243 changed files with 7676 additions and 15825 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 # API base for the browser → Django.
NEXTAUTH_SECRET="" # Dev: http://localhost:8000/ (docker compose maps host :8000 → backend:5000)
NEXTAUTH_URL="" # Prod: leave empty for same-origin (nginx proxies /api, /callback, /auth to Django)
## Portal API VITE_API_URL=http://localhost:8000/
SARLINK_API_BASE_URL=""
## MIB Payments
PAYMENT_VERIFY_BASE_URL=""
## People
PEOPLE_API_URL=""
## SMS
SMS_API_URL=""
SMS_API_KEY=""
## omada
OMADA_BASE_URL=""
OMADA_SITE_ID=
OMADA_GROUP_ID=
OMADA_PROXY_API_KEY=""
-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. # Logs
logs
# dependencies *.log
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# env files (can opt-in for committing if needed) node_modules
.env dist
.env.local dist-ssr
*.local
# Editor directories and files
# vercel .vscode/*
.vercel !.vscode/extensions.json
.idea
# typescript .DS_Store
*.tsbuildinfo *.suo
next-env.d.ts *.ntvs*
*.njsproj
*.sln
#sqlite *.sw?
*.db .env
-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"
}
+23 -38
View File
@@ -1,47 +1,32 @@
This is a web portal for SAR Link customers. # React + TypeScript + Vite
# Todos
## Layout
- [ ] Auto hide the welcome banner after 5 seconds
## User Menu This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
### Devices
- [x] Add mac vendor validation for adding devices
- [x] Add all the filters for devices table (mobile responsive)
- [x] Add cancel feature to selected devices floating button
Currently, two official plugins are available:
### Payments - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [x] Show payments table - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
- [x] Add all the filters for payment table (mobile responsive)
- [x] add slider range filter
- [ ] Fix bill formula linking for generated payments
### Parental Control ## React Compiler
- [x] Fix block device feature
- [x] Add all the filters for parental control table (mobile responsive)
- [x] Disable blocking if payment is pending or omit from the table if device payment is pending
### Topups The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
- [ ] add textarea in topup dialog for admin. if no detail given generate on backend otherwise take the text from text area
### Agreements ## Expanding the Oxlint configuration
- [x] Implement file upload for admin side
- [ ] Add customer relavant documents in a grid view
## Admin Controls If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
### Users
- [x] Show users table
- [x] handle verify api no response case
- [x] Add all relavant filters for users table
- [x] Verify or reject users with a custom message
- [ ] Add functionality to send custom sms to users in user:id page
### User Devices ```json
- [x] Block or unblock from admin with custom message {
- [x] Blocking devices for client and admin using useActionState instead of client side button onClick handlers "$schema": "./node_modules/oxlint/configuration_schema.json",
- [x] Show the devices table "plugins": ["react", "typescript", "oxc"],
- [x] Add all relevant filters for user devices table "options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
### User Payments See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
- [x] Show user payments table
- [x] Add relevant filters for user payments table
-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"
}
}
}
}
-4
View File
@@ -1,4 +0,0 @@
// commitlint.config.js
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>
);
}
+10 -31
View File
@@ -1,32 +1,11 @@
services: services:
node: frontend:
build: build:
context: .build/dev context: .build/dev
dockerfile: node.Dockerfile dockerfile: Dockerfile
hostname: sarlink-portal-dev volumes:
volumes: - ./:/var/www/html/
- ./:/var/www/html/ ports:
ports: - 5173:5173
- 3000:3000 env_file:
prisma-studio: - .env
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:
+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;
+2354 -10695
View File
File diff suppressed because it is too large Load Diff
+20 -84
View File
@@ -1,54 +1,43 @@
{ {
"name": "sarlink-portal", "name": "sarlink-portal-webui",
"version": "0.1.0",
"type": "module",
"private": true, "private": true,
"version": "0.3.0",
"type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "vite",
"build": "next build", "build": "tsc -b && vite build",
"start": "next start", "lint": "oxlint",
"lint": "next lint", "preview": "vite preview"
"prepare": "husky",
"release": "release-it"
}, },
"dependencies": { "dependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@faker-js/faker": "^9.3.0",
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@pyncz/tailwind-mask-image": "^2.0.0", "@pyncz/tailwind-mask-image": "^2.0.0",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-slider": "^1.3.5",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.61.4", "@tanstack/react-query": "^5.61.4",
"axios": "^1.8.4", "axios": "^1.8.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"commitlint": "^19.8.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"husky": "^9.1.7",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"jotai": "2.8.0", "jotai": "2.8.0",
"lucide-react": "^0.523.0", "lucide-react": "^0.523.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"motion": "^12.15.0", "motion": "^12.15.0",
"next": "15.3.3",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-toploader": "^3.7.15",
"nuqs": "^2.4.3", "nuqs": "^2.4.3",
"radix-ui": "^1.4.2", "radix-ui": "^1.4.2",
"react": "19.1.0", "react": "^19.2.8",
"react-aria-components": "^1.5.0", "react-aria-components": "^1.5.0",
"react-day-picker": "^9.7.0", "react-day-picker": "^9.7.0",
"react-dom": "19.1.0", "react-dom": "^19.2.8",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-phone-number-input": "^3.4.9", "react-phone-number-input": "^3.4.9",
"react-resizable-panels": "^3.0.3", "react-resizable-panels": "^3.0.3",
"react-router-dom": "^7.1.1",
"recharts": "^3.0.0", "recharts": "^3.0.0",
"release-it": "^19.0.5",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -56,69 +45,16 @@
"zod": "^3.25.67" "zod": "^3.25.67"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.0.6", "@tailwindcss/vite": "^4.1.11",
"@release-it/conventional-changelog": "^10.0.1", "@types/node": "^24.13.3",
"@types/node": "^22.10.2", "@types/react": "^19.2.17",
"@types/react": "^19.1.0", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.1.2", "@vitejs/plugin-react": "^6.0.4",
"@typescript-eslint/eslint-plugin": "^8.35.0", "oxlint": "^1.75.0",
"@typescript-eslint/parser": "^8.35.0",
"eslint": "^9.29.0",
"eslint-config-next": "15.1.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"tailwindcss-motion": "^1.1.0", "tailwindcss-motion": "^1.1.0",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.3.4",
"typescript": "^5.8.3" "typescript": "~6.0.2",
}, "vite": "^8.2.0"
"release-it": {
"git": {
"commitMessage": "chore: release v${version}"
},
"github": {
"release": true
},
"npm": {
"publish": false
},
"plugins": {
"@release-it/conventional-changelog": {
"infile": "CHANGELOG.md",
"preset": {
"name": "conventionalcommits",
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "chore",
"section": "Chores"
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "refactor",
"section": "Refactor"
},
{
"type": "style",
"section": "Style"
},
{
"type": "test",
"section": "Tests"
}
]
}
}
}
} }
} }
-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 { Clipboard, ClipboardCheck } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -1,4 +1,3 @@
"use client";
import { deviceCartAtom } from "@/lib/atoms"; import { deviceCartAtom } from "@/lib/atoms";
import type { Device } from "@/lib/backend-types"; import type { Device } from "@/lib/backend-types";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
@@ -1,58 +1,51 @@
import { HandCoins } from "lucide-react"; import { HandCoins } from "lucide-react";
import Link from "next/link"; import { useQuery } from "@tanstack/react-query";
import { redirect } from "next/navigation"; import { useAtomValue } from "jotai";
import { getServerSession } from "next-auth"; import { Link, useSearchParams } from "react-router-dom";
import { authOptions } from "@/app/auth";
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { isAdminUser, userAtom } from "@/lib/auth-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getDevices } from "@/queries/devices"; import { getDevices } from "@/queries/devices";
import { tryCatch } from "@/utils/tryCatch";
import BlockDeviceDialog from "../block-device-dialog"; import BlockDeviceDialog from "../block-device-dialog";
import ClientErrorMessage from "../client-error-message"; import ClientErrorMessage from "../client-error-message";
import FullPageLoader from "../full-page-loader";
import Pagination from "../pagination"; import Pagination from "../pagination";
export async function AdminDevicesTable({ export function AdminDevicesTable() {
searchParams, const [searchParams] = useSearchParams();
}: { const user = useAtomValue(userAtom);
searchParams: Promise<{ const isAdmin = isAdminUser(user);
[key: string]: unknown;
}>;
}) {
const resolvedParams = await searchParams;
const session = await getServerSession(authOptions);
const isAdmin = session?.user?.is_admin;
const page = Number.parseInt(resolvedParams.page as string) || 1; const page = Number.parseInt(searchParams.get("page") as string) || 1;
const limit = 10; const limit = 10;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Build params object for getDevices
const apiParams: Record<string, string | number | undefined> = {}; 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 !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = value;
} }
} }
apiParams.limit = limit; apiParams.limit = limit;
apiParams.offset = offset; apiParams.offset = offset;
const [error, devices] = await tryCatch(getDevices(apiParams, true)); const { data: devices, error, isLoading } = useQuery({
if (error) { queryKey: ["admin-devices", apiParams],
if (error.message === "UNAUTHORIZED") { queryFn: () => getDevices(apiParams, true),
redirect("/auth/signin"); });
} else {
return <ClientErrorMessage message={error.message} />; if (isLoading) return <FullPageLoader />;
} if (error) return <ClientErrorMessage message={error.message} />;
} if (!devices) return null;
const { meta, data } = devices; const { meta, data } = devices;
return ( return (
<div> <div>
@@ -64,7 +57,6 @@ export async function AdminDevicesTable({
<> <>
<div> <div>
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Device Name</TableHead> <TableHead>Device Name</TableHead>
@@ -84,7 +76,7 @@ export async function AdminDevicesTable({
"hover:underline font-semibold", "hover:underline font-semibold",
device.is_active ? "text-green-600" : "", device.is_active ? "text-green-600" : "",
)} )}
href={`/devices/${device.id}`} to={`/devices/${device.id}`}
> >
{device.name} {device.name}
</Link> </Link>
@@ -107,7 +99,7 @@ export async function AdminDevicesTable({
</p> </p>
)} )}
{device.has_a_pending_payment && ( {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"> <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{" "} Payment Pending{" "}
<HandCoins className="animate-pulse" size={14} /> <HandCoins className="animate-pulse" size={14} />
@@ -155,9 +147,7 @@ export async function AdminDevicesTable({
{meta?.total === 1 ? ( {meta?.total === 1 ? (
<p className="text-center">Total {meta?.total} device.</p> <p className="text-center">Total {meta?.total} device.</p>
) : ( ) : (
<p className="text-center"> <p className="text-center">Total {meta?.total} devices.</p>
Total {meta?.total} devices.
</p>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -1,8 +1,7 @@
"use client";
import { Loader2, PlusCircle } from "lucide-react"; import { Loader2, PlusCircle } from "lucide-react";
import { useRouter } from "next/navigation"; import { useActionState, useEffect, useState } from "react";
import { useActionState, useEffect, useState } from "react"; // Import useActionState import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { adminUserTopup } from "@/actions/user-actions"; import { adminUserTopup } from "@/actions/user-actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -37,7 +36,8 @@ export const initialState: AddTopupFormState = {
export default function AddTopupDialogForm({ user_id }: { user_id?: string }) { export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const router = useRouter(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
adminUserTopup, adminUserTopup,
initialState, initialState,
@@ -50,12 +50,13 @@ export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
} else if (state.status) { } else if (state.status) {
setOpen(false); setOpen(false);
toast.success(state.message); toast.success(state.message);
router.push("/user-topups?page=1"); queryClient.invalidateQueries({ queryKey: ["topups"] });
navigate("/user-topups?page=1");
} else { } else {
toast.error(state.message); toast.error(state.message);
} }
} }
}, [state, router]); }, [state, navigate, queryClient]);
if (!user_id) { if (!user_id) {
return null; return null;
@@ -104,7 +105,9 @@ export default function AddTopupDialogForm({ user_id }: { user_id?: string }) {
<Label htmlFor="description">Topup Description</Label> <Label htmlFor="description">Topup Description</Label>
<input type="hidden" name="user_id" value={user_id} /> <input type="hidden" name="user_id" value={user_id} />
<Textarea <Textarea
defaultValue={(state?.payload?.get("description") || "") as string} defaultValue={
(state?.payload?.get("description") || "") as string
}
rows={10} rows={10}
name="description" name="description"
id="topup_description" id="topup_description"
@@ -1,50 +1,44 @@
import Link from "next/link"; import { useQuery } from "@tanstack/react-query";
import { redirect } from "next/navigation"; import { Link, useSearchParams } from "react-router-dom";
import { getTopups } from "@/actions/payment"; import { getTopups } from "@/actions/payment";
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } 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 Pagination from "../pagination";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
export async function AdminTopupsTable({ export function AdminTopupsTable() {
searchParams, const [searchParams] = useSearchParams();
}: { const page = Number.parseInt(searchParams.get("page") as string) || 1;
searchParams: Promise<{
[key: string]: unknown;
}>;
}) {
const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1;
const limit = 10; const limit = 10;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Build params object
const apiParams: Record<string, string | number | undefined> = {}; 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 !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = value;
} }
} }
apiParams.limit = limit; apiParams.limit = limit;
apiParams.offset = offset; apiParams.offset = offset;
const [error, topups] = await tryCatch(getTopups(apiParams, true));
if (error) { const { data: topups, error, isLoading } = useQuery({
if (error.message.includes("Unauthorized")) { queryKey: ["topups", apiParams],
redirect("/auth/signin"); queryFn: () => getTopups(apiParams, true),
} else { });
return <pre>{JSON.stringify(error, null, 2)}</pre>;
} if (isLoading) return <FullPageLoader />;
} if (error) return <ClientErrorMessage message={error.message} />;
if (!topups) return null;
const { data, meta } = topups; const { data, meta } = topups;
return ( return (
<div> <div>
@@ -56,7 +50,6 @@ export async function AdminTopupsTable({
<> <>
<div> <div>
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableCaption>Table of all topups.</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User</TableHead> <TableHead>User</TableHead>
@@ -103,7 +96,7 @@ export async function AdminTopupsTable({
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Link <Link
className="font-medium hover:underline" className="font-medium hover:underline"
href={`/top-ups/${topup.id}`} to={`/top-ups/${topup.id}`}
> >
<Button size={"sm"} variant="outline"> <Button size={"sm"} variant="outline">
View Details View Details
@@ -1,5 +1,5 @@
import Link from "next/link"; import { useQuery } from "@tanstack/react-query";
import { redirect } from "next/navigation"; import { Link, useSearchParams } from "react-router-dom";
import { getPayments } from "@/actions/payment"; import { getPayments } from "@/actions/payment";
import Pagination from "@/components/pagination"; import Pagination from "@/components/pagination";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -7,50 +7,42 @@ import { Button } from "@/components/ui/button";
import { import {
Table, Table,
TableBody, TableBody,
TableCaption,
TableCell, TableCell,
TableFooter, TableFooter,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { tryCatch } from "@/utils/tryCatch";
import ClientErrorMessage from "../client-error-message"; import ClientErrorMessage from "../client-error-message";
import FullPageLoader from "../full-page-loader";
export async function UsersPaymentsTable({ export function UsersPaymentsTable() {
searchParams, const [searchParams] = useSearchParams();
}: {
searchParams: Promise<{
[key: string]: unknown;
}>;
}) {
const resolvedParams = await searchParams;
const page = Number.parseInt(resolvedParams.page as string) || 1; const page = Number.parseInt(searchParams.get("page") as string) || 1;
const limit = 10; const limit = 10;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Build params object for getDevices
const apiParams: Record<string, string | number | undefined> = {}; 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 !== "") { if (value !== undefined && value !== "") {
apiParams[key] = typeof value === "number" ? value : String(value); apiParams[key] = value;
} }
} }
apiParams.limit = limit; apiParams.limit = limit;
apiParams.offset = offset; apiParams.offset = offset;
const [error, payments] = await tryCatch(getPayments(apiParams, true)); const { data: payments, error, isLoading } = useQuery({
if (error) { queryKey: ["user-payments", apiParams],
if (error.message === "UNAUTHORIZED") { queryFn: () => getPayments(apiParams, true),
redirect("/auth/signin"); });
} else {
return <ClientErrorMessage message={error.message} />; if (isLoading) return <FullPageLoader />;
} if (error) return <ClientErrorMessage message={error.message} />;
} if (!payments) return null;
const { meta, data } = payments; const { meta, data } = payments;
// return <pre>{JSON.stringify(payments, null, 2)}</pre>;
return ( return (
<div> <div>
{data.length === 0 ? ( {data.length === 0 ? (
@@ -60,7 +52,6 @@ export async function UsersPaymentsTable({
) : ( ) : (
<> <>
<Table className="overflow-scroll"> <Table className="overflow-scroll">
<TableCaption>Table of all users.</TableCaption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Devices paid</TableHead> <TableHead>Devices paid</TableHead>
@@ -93,7 +84,6 @@ export async function UsersPaymentsTable({
</ol> </ol>
</TableCell> </TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
{/* {payment.user.id_card} */}
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
{payment?.user?.name} {payment?.user?.name}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -113,17 +103,11 @@ export async function UsersPaymentsTable({
{payment.status} {payment.status}
</Badge> </Badge>
) : payment.status === "PAID" ? ( ) : payment.status === "PAID" ? (
<Badge <Badge variant="outline" className="bg-lime-100 text-black">
variant="outline"
className="bg-lime-100 text-black"
>
{payment.status} {payment.status}
</Badge> </Badge>
) : ( ) : (
<Badge <Badge variant="outline" className="bg-red-100 text-black">
variant="outline"
className="bg-red-100 text-black"
>
{payment.status} {payment.status}
</Badge> </Badge>
)} )}
@@ -144,7 +128,7 @@ export async function UsersPaymentsTable({
</TableCell> </TableCell>
<TableCell> <TableCell>
<Link href={`/payments/${payment.id}`}> <Link to={`/payments/${payment.id}`}>
<Button>Details</Button> <Button>Details</Button>
</Link> </Link>
</TableCell> </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 { Loader2, User as UserIcon } from "lucide-react";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { useState } from "react"; import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { clearAuth, userAtom } from "@/lib/auth-store";
import { logout } from "@/queries/authentication";
import { tryCatch } from "@/utils/tryCatch";
export function AccountPopover() { export function AccountPopover() {
const session = useSession(); const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const navigate = useNavigate();
if (session.status === "loading") {
<Button variant={"outline"} disabled>
<Loader2 className="animate-spin" />
</Button>;
}
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -30,11 +28,11 @@ export function AccountPopover() {
<div className="grid gap-4"> <div className="grid gap-4">
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium leading-none"> <h4 className="font-medium leading-none">
{session.data?.user?.first_name} {session.data?.user?.last_name} {user?.first_name} {user?.last_name}
</h4> </h4>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
<p className="font-semibold">{session.data?.user?.id_card}</p> <p className="font-semibold">{user?.id_card}</p>
<p>{session.data?.user?.mobile}</p> <p>{user?.mobile}</p>
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -42,13 +40,16 @@ export function AccountPopover() {
disabled={loading} disabled={loading}
onClick={async () => { onClick={async () => {
setLoading(true); setLoading(true);
await signOut(); // Best-effort backend logout, then clear local auth.
await tryCatch(logout());
clearAuth();
setLoading(false); setLoading(false);
navigate("/auth/signin");
}} }}
> >
{loading ? <Loader2 className="animate-spin" /> : "Logout"} {loading ? <Loader2 className="animate-spin" /> : "Logout"}
</Button> </Button>
<Link href="/profile" className="text-muted-foreground"> <Link to="/profile" className="text-muted-foreground">
<Button variant={"secondary"} className="w-full"> <Button variant={"secondary"} className="w-full">
View Profile View Profile
</Button> </Button>
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"; import { useAtomValue } from "jotai";
import { getServerSession } from "next-auth"; import { useQuery } from "@tanstack/react-query";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
import { Outlet } from "react-router-dom";
import { getProfile } from "@/actions/user-actions"; import { getProfile } from "@/actions/user-actions";
import { authOptions } from "@/app/auth";
import { DeviceCartDrawer } from "@/components/device-cart"; import { DeviceCartDrawer } from "@/components/device-cart";
import { ModeToggle } from "@/components/theme-toggle"; import { ModeToggle } from "@/components/theme-toggle";
import { AppSidebar } from "@/components/ui/app-sidebar"; import { AppSidebar } from "@/components/ui/app-sidebar";
@@ -13,23 +13,22 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Wallet } from "@/components/wallet"; import { Wallet } from "@/components/wallet";
import { tryCatch } from "@/utils/tryCatch"; import { userAtom } from "@/lib/auth-store";
import { WelcomeBanner } from "../welcome-banner"; import { WelcomeBanner } from "../welcome-banner";
import { AccountPopover } from "./account-popver"; import { AccountPopover } from "./account-popver";
export async function ApplicationLayout({ export function ApplicationLayout() {
children, const user = useAtomValue(userAtom);
}: {
children: React.ReactNode; // Fresh profile (wallet balance) — replaces the old server-side getProfile.
}) { // Falls back to the login snapshot in userAtom while loading.
const session = await getServerSession(authOptions); 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 ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
@@ -41,18 +40,20 @@ export async function ApplicationLayout({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wallet walletBalance={userProfile?.wallet_balance || 0} /> <Wallet walletBalance={walletBalance} />
<ModeToggle /> <ModeToggle />
<AccountPopover /> <AccountPopover />
</div> </div>
</header> </header>
<WelcomeBanner <WelcomeBanner
firstName={session?.user?.first_name} firstName={user?.first_name}
lastName={session?.user?.last_name} lastName={user?.last_name}
/> />
<DeviceCartDrawer /> <DeviceCartDrawer />
<div className="p-4 flex flex-col flex-1 rounded-lg bg-background"> <div className="p-4 flex flex-col flex-1 rounded-lg bg-background">
<NuqsAdapter>{children}</NuqsAdapter> <NuqsAdapter>
<Outlet />
</NuqsAdapter>
</div> </div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
@@ -1,11 +1,9 @@
export default function AuthLayout({ import { Outlet } from "react-router-dom";
children,
}: { export function AuthLayout() {
children: React.ReactNode;
}) {
return ( return (
<div className="bg-gray-100 dark:bg-black w-full h-screen flex items-center justify-center font-sans"> <div className="bg-gray-100 dark:bg-black w-full h-screen flex items-center justify-center font-sans">
{children} <Outlet />
</div> </div>
); );
} }
@@ -1,18 +1,24 @@
"use client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { signin } from "@/actions/auth-actions"; import { signin } from "@/actions/auth-actions";
import { Loader2 } from "lucide-react"; 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"; import { PhoneInput } from "../ui/phone-input";
export default function LoginForm() { export default function LoginForm() {
const navigate = useNavigate();
const [state, formAction, isPending] = useActionState(signin, { const [state, formAction, isPending] = useActionState(signin, {
message: "", message: "",
status: "", status: "",
}); });
useEffect(() => {
if (state.status === "redirect" && state.redirectTo) {
navigate(state.redirectTo);
}
}, [state, navigate]);
return ( return (
<form <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" 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}</>;
}
@@ -1,10 +1,7 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { signup } from "@/actions/auth-actions"; import { signup } from "@/actions/auth-actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -22,6 +19,7 @@ import { cn } from "@/lib/utils";
import { getAtolls } from "@/queries/islands"; import { getAtolls } from "@/queries/islands";
export default function SignUpForm() { export default function SignUpForm() {
const navigate = useNavigate();
const { data: atolls } = useQuery<ApiResponse<Atoll>>({ const { data: atolls } = useQuery<ApiResponse<Atoll>>({
queryKey: ["ATOLLS"], queryKey: ["ATOLLS"],
queryFn: () => getAtolls(), queryFn: () => getAtolls(),
@@ -30,6 +28,7 @@ export default function SignUpForm() {
}); });
const [atoll, setAtoll] = React.useState<Atoll>(); const [atoll, setAtoll] = React.useState<Atoll>();
const [islandId, setIslandId] = React.useState<string>("");
const [actionState, action, isPending] = React.useActionState(signup, { const [actionState, action, isPending] = React.useActionState(signup, {
message: "", message: "",
@@ -38,11 +37,31 @@ export default function SignUpForm() {
payload: new FormData(), payload: new FormData(),
}); });
// After a failed submit, restore the atoll/island the user had picked (the
// FormData payload carries atoll_id / island_id) so the dropdowns don't reset.
React.useEffect(() => { React.useEffect(() => {
console.log(atoll); const payloadAtollId = actionState?.payload?.get("atoll_id") as
}, [atoll]); | string
| null;
const payloadIslandId = actionState?.payload?.get("island_id") as
| string
| null;
if (payloadAtollId && atolls?.data) {
const found = atolls.data.find(
(a) => a.id === Number.parseInt(payloadAtollId),
);
if (found) setAtoll(found);
}
if (payloadIslandId) setIslandId(payloadIslandId);
}, [actionState, atolls]);
const params = useSearchParams(); React.useEffect(() => {
if (actionState?.status === "redirect" && actionState.redirectTo) {
navigate(actionState.redirectTo);
}
}, [actionState, navigate]);
const [params] = useSearchParams();
const phoneNumberFromUrl = params.get("phone_number"); const phoneNumberFromUrl = params.get("phone_number");
const NUMBER_WITHOUT_DASH = phoneNumberFromUrl?.split("-").join(""); const NUMBER_WITHOUT_DASH = phoneNumberFromUrl?.split("-").join("");
@@ -54,7 +73,7 @@ export default function SignUpForm() {
</div> </div>
<div className="mb-4 text-center text-sm"> <div className="mb-4 text-center text-sm">
Go to{" "} Go to{" "}
<Link href="login" className="underline"> <Link to="login" className="underline">
login login
</Link> </Link>
</div> </div>
@@ -71,11 +90,9 @@ export default function SignUpForm() {
{/* Logo */} {/* Logo */}
<div className="mb-8"> <div className="mb-8">
<div className="w-20 h-20 bg-transparent backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4"> <div className="w-20 h-20 bg-transparent backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4">
<Image <img
src="/logo.png" src="/logo.png"
alt="Company Logo" alt="Company Logo"
height={1080}
width={1080}
className="w-12 h-12 text-white" className="w-12 h-12 text-white"
/> />
</div> </div>
@@ -110,7 +127,7 @@ export default function SignUpForm() {
className={cn( className={cn(
"text-base", "text-base",
actionState?.errors?.fieldErrors.name && actionState?.errors?.fieldErrors.name &&
"border-2 border-red-500", "border-2 border-red-500",
)} )}
name="name" name="name"
type="text" type="text"
@@ -144,7 +161,7 @@ export default function SignUpForm() {
className={cn( className={cn(
"text-base", "text-base",
actionState?.errors?.fieldErrors?.id_card && actionState?.errors?.fieldErrors?.id_card &&
"border-2 border-red-500", "border-2 border-red-500",
)} )}
placeholder="ID Card" placeholder="ID Card"
/> />
@@ -170,12 +187,13 @@ export default function SignUpForm() {
<Select <Select
disabled={isPending} disabled={isPending}
onValueChange={(v) => { onValueChange={(v) => {
console.log({ v });
setAtoll( setAtoll(
atolls?.data.find( atolls?.data.find(
(atoll) => atoll.id === Number.parseInt(v), (atoll) => atoll.id === Number.parseInt(v),
), ),
); );
// Reset island — its options depend on the atoll.
setIslandId("");
}} }}
name="atoll_id" name="atoll_id"
value={atoll?.id?.toString() ?? ""} value={atoll?.id?.toString() ?? ""}
@@ -186,7 +204,7 @@ export default function SignUpForm() {
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectLabel>Atolls</SelectLabel> <SelectLabel>Atolls</SelectLabel>
{atolls?.data.map((atoll) => ( {(atolls?.data ?? []).map((atoll) => (
<SelectItem key={atoll.id} value={atoll.id.toString()}> <SelectItem key={atoll.id} value={atoll.id.toString()}>
{atoll.name} {atoll.name}
</SelectItem> </SelectItem>
@@ -207,7 +225,12 @@ export default function SignUpForm() {
> >
Island Island
</label> </label>
<Select disabled={isPending} name="island_id"> <Select
disabled={isPending}
name="island_id"
value={islandId}
onValueChange={setIslandId}
>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select island" /> <SelectValue placeholder="Select island" />
</SelectTrigger> </SelectTrigger>
@@ -244,7 +267,7 @@ export default function SignUpForm() {
className={cn( className={cn(
"text-base", "text-base",
actionState?.errors?.fieldErrors?.address && actionState?.errors?.fieldErrors?.address &&
"border-2 border-red-500", "border-2 border-red-500",
)} )}
disabled={isPending} disabled={isPending}
name="address" name="address"
@@ -272,7 +295,7 @@ export default function SignUpForm() {
className={cn( className={cn(
"text-base", "text-base",
actionState?.errors?.fieldErrors?.dob && actionState?.errors?.fieldErrors?.dob &&
"border-2 border-red-500", "border-2 border-red-500",
)} )}
name="dob" name="dob"
disabled={isPending} disabled={isPending}
@@ -305,7 +328,7 @@ export default function SignUpForm() {
className={cn( className={cn(
"text-base", "text-base",
actionState?.errors?.fieldErrors.accNo && actionState?.errors?.fieldErrors.accNo &&
"border-2 border-red-500", "border-2 border-red-500",
)} )}
name="accNo" name="accNo"
type="number" type="number"
@@ -335,8 +358,8 @@ export default function SignUpForm() {
disabled={isPending} disabled={isPending}
className={cn( className={cn(
!phoneNumberFromUrl && !phoneNumberFromUrl &&
actionState?.errors?.fieldErrors?.phone_number && actionState?.errors?.fieldErrors?.phone_number &&
"border-2 border-red-500 rounded-md", "border-2 border-red-500 rounded-md",
)} )}
defaultValue={NUMBER_WITHOUT_DASH ?? ""} defaultValue={NUMBER_WITHOUT_DASH ?? ""}
readOnly={Boolean(phoneNumberFromUrl)} readOnly={Boolean(phoneNumberFromUrl)}
@@ -369,7 +392,7 @@ export default function SignUpForm() {
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<span>i accept</span> <span>i accept</span>
<Link className="ml-1 underline" href=""> <Link className="ml-1 underline" to="">
terms and conditions terms and conditions
</Link> </Link>
</label> </label>
@@ -394,7 +417,7 @@ export default function SignUpForm() {
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
> >
<span>i undertand</span> <span>i undertand</span>
<Link className="ml-1 underline" href=""> <Link className="ml-1 underline" to="">
the privacy policy the privacy policy
</Link> </Link>
</label> </label>
@@ -411,7 +434,7 @@ export default function SignUpForm() {
</div> </div>
<div className="mb-4 text-center text-sm"> <div className="mb-4 text-center text-sm">
Already have an account?{" "} Already have an account?{" "}
<Link href="signin" className="underline"> <Link to="signin" className="underline">
login login
</Link> </Link>
</div> </div>

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