This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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;
|
||||
};
|
||||
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) {
|
||||
return {
|
||||
status: "redirect",
|
||||
redirectTo: `/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",
|
||||
};
|
||||
}
|
||||
|
||||
await apiClient.post("/auth/mobile/", { mobile: FORMATTED_MOBILE_NUMBER });
|
||||
|
||||
return {
|
||||
status: "redirect",
|
||||
redirectTo: `/auth/verify-otp?phone_number=${FORMATTED_MOBILE_NUMBER}`,
|
||||
};
|
||||
}
|
||||
|
||||
export type ActionState = {
|
||||
status?: string;
|
||||
redirectTo?: string;
|
||||
message?: string;
|
||||
payload?: FormData;
|
||||
errors?: z.typeToFlattenedError<
|
||||
{
|
||||
id_card: string;
|
||||
phone_number: string;
|
||||
name: string;
|
||||
atoll_id: string;
|
||||
island_id: string;
|
||||
address: string;
|
||||
dob: Date;
|
||||
terms: string;
|
||||
policy: string;
|
||||
accNo: string;
|
||||
},
|
||||
string
|
||||
>;
|
||||
db_error?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function signup(_actionState: ActionState, formData: FormData) {
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
const parsedData = signUpFormSchema.safeParse(data);
|
||||
|
||||
if (!parsedData.success) {
|
||||
return {
|
||||
message: "Invalid form data",
|
||||
payload: formData,
|
||||
errors: parsedData.error.flatten(),
|
||||
};
|
||||
}
|
||||
const age =
|
||||
new Date().getFullYear() - new Date(parsedData.data.dob).getFullYear();
|
||||
if (age < 18) {
|
||||
return {
|
||||
message: "You must be at least 18 years old to register.",
|
||||
payload: formData,
|
||||
db_error: "dob",
|
||||
};
|
||||
}
|
||||
|
||||
const idCardExists = await checkIdOrPhone({
|
||||
id_card: parsedData.data.id_card,
|
||||
});
|
||||
if (idCardExists.ok) {
|
||||
return {
|
||||
message: "ID card already exists.",
|
||||
payload: formData,
|
||||
db_error: "id_card",
|
||||
};
|
||||
}
|
||||
|
||||
const phoneNumberExists = await checkIdOrPhone({
|
||||
phone_number: parsedData.data.phone_number,
|
||||
});
|
||||
|
||||
const tempPhoneNumberExists = await checkTempIdOrPhone({
|
||||
phone_number: parsedData.data.phone_number,
|
||||
});
|
||||
if (phoneNumberExists.ok || tempPhoneNumberExists.ok) {
|
||||
return {
|
||||
message: "Phone number already exists.",
|
||||
payload: formData,
|
||||
db_error: "phone_number",
|
||||
};
|
||||
}
|
||||
|
||||
const [signupError, signupResponse] = await tryCatch(
|
||||
backendRegister({
|
||||
payload: {
|
||||
firstname: parsedData.data.name.split(" ")[0],
|
||||
lastname: parsedData.data.name.split(" ").slice(1).join(" "),
|
||||
username: parsedData.data.phone_number,
|
||||
address: parsedData.data.address,
|
||||
id_card: parsedData.data.id_card,
|
||||
dob: new Date(parsedData.data.dob).toISOString().split("T")[0],
|
||||
mobile: parsedData.data.phone_number,
|
||||
island: Number.parseInt(parsedData.data.island_id),
|
||||
atoll: Number.parseInt(parsedData.data.atoll_id),
|
||||
acc_no: parsedData.data.accNo,
|
||||
terms_accepted: parsedData.data.terms,
|
||||
policy_accepted: parsedData.data.policy,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (signupError) {
|
||||
return {
|
||||
message: signupError.message,
|
||||
payload: formData,
|
||||
db_error: "phone_number",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "User created successfully",
|
||||
error: "success",
|
||||
status: "redirect",
|
||||
redirectTo: `/auth/verify-otp-registration?phone_number=${encodeURIComponent(
|
||||
signupResponse.t_username,
|
||||
)}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
ApiError,
|
||||
ApiResponse,
|
||||
NewPayment,
|
||||
Payment,
|
||||
Topup,
|
||||
} from "@/lib/backend-types";
|
||||
import type { TopupResponse } from "@/lib/types";
|
||||
import apiClient from "@/lib/api-client";
|
||||
import { handleApiResponse } from "@/utils/tryCatch";
|
||||
|
||||
type GenericGetResponseProps = {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
|
||||
function buildQuery(params: Record<string, string | number | undefined>) {
|
||||
return Object.entries(params)
|
||||
.filter(([_, value]) => value !== undefined && value !== "")
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`,
|
||||
)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export async function createPayment(data: NewPayment) {
|
||||
const response = await apiClient.post("/api/billing/payment/", data);
|
||||
return handleApiResponse<Payment>(response, "createPayment");
|
||||
}
|
||||
|
||||
export async function createTopup(data: { amount: number }) {
|
||||
const response = await apiClient.post("/api/billing/topup/", data);
|
||||
return handleApiResponse<Topup>(response, "createTopup");
|
||||
}
|
||||
|
||||
export async function getPayment({ id }: { id: string }) {
|
||||
const response = await apiClient.get(`/api/billing/payment/${id}`);
|
||||
return handleApiResponse<Payment>(response, "getPayment");
|
||||
}
|
||||
|
||||
type GetPaymentProps = {
|
||||
[key: string]: string | number | undefined;
|
||||
};
|
||||
|
||||
export async function getPayments(params: GetPaymentProps, allPayments = false) {
|
||||
const query = buildQuery(params);
|
||||
const response = await apiClient.get(
|
||||
`/api/billing/payment/?${query}&all_payments=${allPayments}`,
|
||||
);
|
||||
return handleApiResponse<ApiResponse<Payment>>(response, "getPayments");
|
||||
}
|
||||
|
||||
export async function getTopups(
|
||||
params: GenericGetResponseProps,
|
||||
all_topups = false,
|
||||
) {
|
||||
const query = buildQuery(params);
|
||||
const response = await apiClient.get(
|
||||
`/api/billing/topup/?${query}&all_topups=${all_topups}`,
|
||||
);
|
||||
return handleApiResponse<ApiResponse<Topup>>(response, "getTopups");
|
||||
}
|
||||
|
||||
export async function getTopup({ id }: { id: string }) {
|
||||
const response = await apiClient.get(`/api/billing/topup/${id}`);
|
||||
return handleApiResponse<Topup>(response, "getTopup");
|
||||
}
|
||||
|
||||
export async function cancelTopup({ id }: { id: string }) {
|
||||
const response = await apiClient.patch(`/api/billing/topup/${id}/cancel/`);
|
||||
return handleApiResponse<Topup>(response, "cancelTopup");
|
||||
}
|
||||
|
||||
export async function cancelPayment({ id }: { id: string }) {
|
||||
const response = await apiClient.patch(`/api/billing/payment/${id}/cancel/`);
|
||||
return handleApiResponse<Payment>(response, "cancelPayment");
|
||||
}
|
||||
|
||||
type UpdatePayment = {
|
||||
id: string;
|
||||
method: "TRANSFER" | "WALLET";
|
||||
};
|
||||
export async function verifyPayment({ id, method }: UpdatePayment) {
|
||||
const response = await apiClient.put(`/api/billing/payment/${id}/verify/`, {
|
||||
method,
|
||||
});
|
||||
return handleApiResponse<Payment>(response, "verifyPayment");
|
||||
}
|
||||
|
||||
export type VerifyDevicePaymentState = {
|
||||
payment?: Payment;
|
||||
message: string;
|
||||
success: boolean;
|
||||
fieldErrors: Record<string, string>;
|
||||
payload?: FormData;
|
||||
};
|
||||
|
||||
export async function verifyDevicePayment(
|
||||
_prevState: VerifyDevicePaymentState,
|
||||
formData: FormData,
|
||||
): Promise<VerifyDevicePaymentState> {
|
||||
const paymentId = formData.get("paymentId") as string;
|
||||
const method = formData.get("method") as "TRANSFER" | "WALLET";
|
||||
|
||||
if (!paymentId) {
|
||||
return {
|
||||
message: "Payment ID is required",
|
||||
success: false,
|
||||
fieldErrors: { paymentId: "Payment ID is required" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!method) {
|
||||
return {
|
||||
message: "Payment method is required",
|
||||
success: false,
|
||||
fieldErrors: { method: "Payment method is required" },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
`/api/billing/payment/${paymentId}/verify/`,
|
||||
{ method },
|
||||
);
|
||||
const result = handleApiResponse<Payment>(response, "verifyDevicePayment");
|
||||
|
||||
return {
|
||||
message:
|
||||
method === "WALLET"
|
||||
? "Payment completed successfully using wallet!"
|
||||
: "Payment verification successful!",
|
||||
success: true,
|
||||
fieldErrors: {},
|
||||
payment: result,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const fallback =
|
||||
"Unable to verify payment. Please try again or contact support.";
|
||||
return {
|
||||
message: error instanceof Error ? error.message || fallback : fallback,
|
||||
success: false,
|
||||
fieldErrors: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type VerifyTopupPaymentState = {
|
||||
transaction?: {
|
||||
sourceBank: string;
|
||||
trxDate: string;
|
||||
};
|
||||
message: string;
|
||||
success: boolean;
|
||||
fieldErrors: Record<string, string>;
|
||||
payload?: FormData;
|
||||
};
|
||||
export async function verifyTopupPayment(
|
||||
_prevState: VerifyTopupPaymentState,
|
||||
formData: FormData,
|
||||
): Promise<VerifyTopupPaymentState> {
|
||||
const topupId = formData.get("topupId") as string;
|
||||
|
||||
if (!topupId) {
|
||||
return {
|
||||
message: "Topup ID is required",
|
||||
success: false,
|
||||
fieldErrors: { topupId: "Topup ID is required" },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
`/api/billing/topup/${topupId}/verify/`,
|
||||
);
|
||||
const result = handleApiResponse<TopupResponse>(
|
||||
response,
|
||||
"verifyTopupPayment",
|
||||
);
|
||||
|
||||
return {
|
||||
message: result.message || "Topup payment verified successfully",
|
||||
success: true,
|
||||
fieldErrors: {},
|
||||
transaction: result.transaction,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const fallback =
|
||||
"Unable to verify payment. Please try again or contact support.";
|
||||
return {
|
||||
message: error instanceof Error ? error.message || fallback : fallback,
|
||||
success: false,
|
||||
fieldErrors: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { RejectUserFormState } from "@/components/user/user-reject-dialog";
|
||||
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 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 response = await apiClient.delete(
|
||||
`/api/auth/users/${userId}/reject/`,
|
||||
{ data: { rejection_details } },
|
||||
);
|
||||
|
||||
if (response.status === 204) {
|
||||
return {
|
||||
message: "User rejected successfully!",
|
||||
fieldErrors: {},
|
||||
payload: formData,
|
||||
redirectTo: "/users",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const errorData = (response.data ?? {}) as ApiError;
|
||||
throw new Error(
|
||||
errorData.message || errorData.detail || "Failed to reject user",
|
||||
);
|
||||
}
|
||||
|
||||
const error = (response.data ?? {}) as ApiError;
|
||||
return {
|
||||
message: error.message || error.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 (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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user