From cd1dba06f014b186d659783276781df1b3d0129e Mon Sep 17 00:00:00 2001 From: i701 Date: Fri, 4 Jul 2025 23:04:38 +0500 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20update=20progress=20component?= =?UTF-8?q?=20styles=20and=20add=20radix-ui/react-progress=20dependency=20?= =?UTF-8?q?=F0=9F=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/progress.tsx | 6 +++--- package-lock.json | 1 + package.json | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx index 51dbd76..37a7075 100644 --- a/components/ui/progress.tsx +++ b/components/ui/progress.tsx @@ -1,7 +1,7 @@ "use client" -import * as React from "react" import { Progress as ProgressPrimitive } from "radix-ui" +import * as React from "react" import { cn } from "@/lib/utils" @@ -12,13 +12,13 @@ const Progress = React.forwardRef< diff --git a/package-lock.json b/package-lock.json index 9289eb2..dafbdee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^5.1.1", "@pyncz/tailwind-mask-image": "^2.0.0", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", "@tailwindcss/postcss": "^4.1.11", "@tanstack/react-query": "^5.61.4", "axios": "^1.8.4", diff --git a/package.json b/package.json index 55a1294..bc76448 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^5.1.1", "@pyncz/tailwind-mask-image": "^2.0.0", "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", "@tailwindcss/postcss": "^4.1.11", "@tanstack/react-query": "^5.61.4", "axios": "^1.8.4", From 89a35a96742e44bcaba2ee72597294365e64f573 Mon Sep 17 00:00:00 2001 From: i701 Date: Fri, 4 Jul 2025 23:05:30 +0500 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20add=20"Top=20Ups"=20option=20to=20s?= =?UTF-8?q?idebar=20with=20appropriate=20permissions=20and=20icon=20?= =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/app-sidebar.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/components/ui/app-sidebar.tsx b/components/ui/app-sidebar.tsx index 4332501..a156e9d 100644 --- a/components/ui/app-sidebar.tsx +++ b/components/ui/app-sidebar.tsx @@ -1,4 +1,5 @@ import { + BadgePlus, Calculator, ChevronRight, Coins, @@ -73,6 +74,12 @@ export async function AppSidebar({ icon: , perm_identifier: "payment", }, + { + title: "Top Ups", + link: "/top-ups?page=1", + icon: , + perm_identifier: "topup", + }, { title: "Parental Control", link: "/parental-control", From ee461bbbf804d94acacea34d928fd2a8c2b76fca Mon Sep 17 00:00:00 2001 From: i701 Date: Fri, 4 Jul 2025 23:06:13 +0500 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20implement=20topup=20functionality?= =?UTF-8?q?=20with=20create,=20get,=20and=20cancel=20operations=20?= =?UTF-8?q?=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- actions/payment.ts | 116 ++++++++++++++++++++++++++++++++++++++++--- lib/backend-types.ts | 14 ++++++ lib/types.ts | 14 +++++- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/actions/payment.ts b/actions/payment.ts index a2a156b..fdc0544 100644 --- a/actions/payment.ts +++ b/actions/payment.ts @@ -1,25 +1,31 @@ "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 type { User } from "@/lib/types/user"; -import { checkSession } from "@/utils/session"; -import { handleApiResponse, tryCatch } from "@/utils/tryCatch"; -import { getServerSession } from "next-auth"; -import { revalidatePath } from "next/cache"; -import { redirect } from "next/navigation"; +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 // }); + `${process.env.SARLINK_API_BASE_URL // }); }/api/billing/payment/`, { method: "POST", @@ -43,6 +49,22 @@ export async function createPayment(data: NewPayment) { 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(response, "createTopup"); +} + export async function getPayment({ id }: { id: string }) { const session = await getServerSession(authOptions); const response = await fetch( @@ -92,6 +114,67 @@ export async function getPayments() { return data; } +export async function getTopups(params: GenericGetResponseProps) { + + // 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}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Token ${session?.apiToken}`, + }, + }, + ); + return handleApiResponse>(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(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}/delete/`, + { + method: "DELETE", + 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; + } + return { message: "Topup successfully canceled." }; +} + export async function cancelPayment({ id }: { id: string }) { const session = await getServerSession(authOptions); const response = await fetch( @@ -142,6 +225,25 @@ export async function verifyPayment({ id, method }: UpdatePayment) { return handleApiResponse(response, "updatePayment"); } +type UpdateTopupPayment = { + id: string; +}; + +export async function verifyTopupPayment({ id }: UpdateTopupPayment) { + const session = await getServerSession(authOptions); + const response = await fetch( + `${process.env.SARLINK_API_BASE_URL}/api/billing/topup/${id}/verify/`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Token ${session?.apiToken}`, + }, + }, + ); + revalidatePath("/top-ups/[topupId]", "page"); + return handleApiResponse(response, "verifyTopupPayment"); +} export async function getProfile() { const session = await getServerSession(authOptions); diff --git a/lib/backend-types.ts b/lib/backend-types.ts index 6d80a85..1053660 100644 --- a/lib/backend-types.ts +++ b/lib/backend-types.ts @@ -57,6 +57,20 @@ export interface ApiError { detail?: string; } +export interface Topup { + id: string; + amount: number; + user: Pick & { + name: string; + }; + paid: boolean; + mib_reference: string | null; + expires_at: string; + is_expired: boolean; + created_at: string; + updated_at: string; +} + export interface Payment { id: string; devices: Device[]; diff --git a/lib/types.ts b/lib/types.ts index 5fcbb85..beebbe7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,9 +1,19 @@ export type TopupType = { amount: number; - userId: string; - paid: boolean; }; +export type Transaction = { + ref: string; + sourceBank: string; + trxDate: string; +} + +export type TopupResponse = { + status: boolean; + message: string; + transaction?: Transaction +} + interface IpAddress { ip: string; mask: number; From f3328f7a7bb1add18734b7477bf7eba2ca3eb0e9 Mon Sep 17 00:00:00 2001 From: i701 Date: Fri, 4 Jul 2025 23:07:02 +0500 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20add=20topup=20management=20features?= =?UTF-8?q?=20including=20topup=20creation,=20cancellation,=20and=20countd?= =?UTF-8?q?own=20timer=20=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(dashboard)/top-ups/[topupId]/page.tsx | 72 ++++++ app/(dashboard)/top-ups/page.tsx | 63 +++++ components/billing/cancel-topup-button.tsx | 37 +++ components/billing/expiry-time-countdown.tsx | 57 +++++ components/topup-to-pay.tsx | 177 ++++++++++++++ components/topups-table.tsx | 235 +++++++++++++++++++ components/wallet.tsx | 37 ++- 7 files changed, 657 insertions(+), 21 deletions(-) create mode 100644 app/(dashboard)/top-ups/[topupId]/page.tsx create mode 100644 app/(dashboard)/top-ups/page.tsx create mode 100644 components/billing/cancel-topup-button.tsx create mode 100644 components/billing/expiry-time-countdown.tsx create mode 100644 components/topup-to-pay.tsx create mode 100644 components/topups-table.tsx diff --git a/app/(dashboard)/top-ups/[topupId]/page.tsx b/app/(dashboard)/top-ups/[topupId]/page.tsx new file mode 100644 index 0000000..bba63f6 --- /dev/null +++ b/app/(dashboard)/top-ups/[topupId]/page.tsx @@ -0,0 +1,72 @@ +import { redirect } from "next/navigation"; +import { getTopup } from "@/actions/payment"; +import CancelTopupButton from "@/components/billing/cancel-topup-button"; +import ExpiryCountDown from "@/components/billing/expiry-time-countdown"; +import ClientErrorMessage from "@/components/client-error-message"; +import TopupToPay from "@/components/topup-to-pay"; +import { Button } from "@/components/ui/button"; +import { TextShimmer } from "@/components/ui/text-shimmer"; +import { cn } from "@/lib/utils"; +import { tryCatch } from "@/utils/tryCatch"; +export default async function TopupPage({ + params, +}: { + params: Promise<{ topupId: string }>; +}) { + const topupId = (await params).topupId; + const [error, topup] = await tryCatch(getTopup({ id: topupId })); + if (error) { + if (error.message === "Invalid token.") redirect("/auth/signin"); + return ; + } + + + return ( +
+
+

Topup

+
+ {!topup.is_expired && ( + + + )} + + {!topup.paid && ( + topup.is_expired ? ( + + ) : ( + + ) + )} +
+
+ {!topup.paid && ( + + )} +
+ +
+
+ ); +} diff --git a/app/(dashboard)/top-ups/page.tsx b/app/(dashboard)/top-ups/page.tsx new file mode 100644 index 0000000..8b27ed9 --- /dev/null +++ b/app/(dashboard)/top-ups/page.tsx @@ -0,0 +1,63 @@ +import { Suspense } from "react"; +import DynamicFilter from "@/components/generic-filter"; +import { TopupsTable } from "@/components/topups-table"; + +export default async function Topups({ + searchParams, +}: { + searchParams: Promise<{ + query: string; + page: number; + sortBy: string; + status: string; + }>; +}) { + const query = (await searchParams)?.query || ""; + + return ( +
+
+

My Topups

+
+
+ +
+ + + +
+ ); +} diff --git a/components/billing/cancel-topup-button.tsx b/components/billing/cancel-topup-button.tsx new file mode 100644 index 0000000..a6ca438 --- /dev/null +++ b/components/billing/cancel-topup-button.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Loader2, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import React from "react"; +import { toast } from "sonner"; +import { cancelTopup } from "@/actions/payment"; +import { tryCatch } from "@/utils/tryCatch"; +import { Button } from "../ui/button"; + +export default function CancelTopupButton({ + topupId, +}: { topupId: string }) { + const router = useRouter(); + const [loading, setLoading] = React.useState(false); + return ( + + ); +} diff --git a/components/billing/expiry-time-countdown.tsx b/components/billing/expiry-time-countdown.tsx new file mode 100644 index 0000000..a5a7188 --- /dev/null +++ b/components/billing/expiry-time-countdown.tsx @@ -0,0 +1,57 @@ +'use client' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { Progress } from '@/components/ui/progress' + +const calculateTimeLeft = (expiresAt: string) => { + const now = Date.now() + const expirationTime = new Date(expiresAt).getTime() + return Math.max(0, Math.floor((expirationTime - now) / 1000)) +} + +const HumanizeTimeLeft = (seconds: number) => { + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` +} + +export default function ExpiryCountDown({ expiresAt }: { expiresAt: string }) { + const [timeLeft, setTimeLeft] = useState(calculateTimeLeft(expiresAt)) + const [mounted, setMounted] = useState(false) + const router = useRouter() + const pathname = usePathname() + useEffect(() => { + setMounted(true) + }, []) + + useEffect(() => { + const timer = setInterval(() => { + setTimeLeft(calculateTimeLeft(expiresAt)) + }, 1000) + + return () => clearInterval(timer) + }, [expiresAt]) + + useEffect(() => { + if (timeLeft <= 0) { + router.replace(pathname) + } + }, [timeLeft, router, pathname]) + + if (!mounted) { + return null + } + return ( +
+
+ {timeLeft ? ( + Time left: {HumanizeTimeLeft(timeLeft)} + ) : ( + Top up has expired. Please make another topup to add balance to your wallet. + )} + {timeLeft > 0 && ( + + )} +
+ ) +} diff --git a/components/topup-to-pay.tsx b/components/topup-to-pay.tsx new file mode 100644 index 0000000..e3d160d --- /dev/null +++ b/components/topup-to-pay.tsx @@ -0,0 +1,177 @@ +"use client"; +import { + BadgeDollarSign, + Clipboard, + ClipboardCheck, + Loader2, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { verifyTopupPayment } from "@/actions/payment"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableRow, +} from "@/components/ui/table"; +import type { Topup } from "@/lib/backend-types"; +import { Button } from "./ui/button"; + +export default function TopupToPay({ topup, disabled }: { topup?: Topup, disabled?: boolean }) { + const [verifyingTransferPayment, setVerifyingTransferPayment] = + useState(false); + + return ( +
+
+ + +
+

Please send the following amount to the payment address

+ + {topup?.paid ? ( + + ) : ( +
+ +
+ )} +
+
+ + + Topup created at + + {new Date(topup?.created_at ?? "").toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + minute: "2-digit", + hour: "2-digit", + second: "2-digit", + })} + + + + Topup expires at + + {new Date(topup?.expires_at ?? "").toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + minute: "2-digit", + hour: "2-digit", + second: "2-digit", + })} + + + + MIB Reference + + {topup?.mib_reference ? topup.mib_reference : "N/A"} + + + + + + Total Due + + {topup?.amount?.toFixed(2)} + + + +
+
+
+ ); +} + +function AccountInfomation({ + accountNo, + accName, +}: { + accountNo: string; + accName: string; +}) { + const [accNo, setAccNo] = useState(false); + return ( +
+
+ Account Information +
+
+
Account Name
+ {accName} +
+
+
+

Account No

+ {accountNo} +
+ +
+
+ ); +} diff --git a/components/topups-table.tsx b/components/topups-table.tsx new file mode 100644 index 0000000..9b01783 --- /dev/null +++ b/components/topups-table.tsx @@ -0,0 +1,235 @@ +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; + + // Build params object + const apiParams: Record = {}; + for (const [key, value] of Object.entries(resolvedParams)) { + if (value !== undefined && value !== "") { + apiParams[key] = typeof value === "number" ? value : String(value); + } + } + + const [error, topups] = await tryCatch(getTopups(apiParams)); + + if (error) { + if (error.message.includes("Unauthorized")) { + redirect("/auth/signin"); + } else { + return
{JSON.stringify(error, null, 2)}
; + } + } + const { data, meta } = topups; + return ( +
+ {data?.length === 0 ? ( +
+

No topups yet.

+
+ ) : ( + <> +
+ + Table of all topups. + + + Details + Expires at + Expired + Amount + + + + {topups?.data?.map((topup) => ( + + +
+
+ + + {new Date(topup.created_at).toLocaleDateString( + "en-US", + { + month: "short", + day: "2-digit", + year: "numeric", + minute: "2-digit", + hour: "2-digit", + }, + )} + +
+ +
+ + + + {!topup.is_expired && ( + + + {topup.paid ? "Paid" : "Unpaid"} + + )} +
+
+
+ + + {new Date(topup.expires_at).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + minute: "2-digit", + hour: "2-digit", + })} + + + + + {topup.is_expired ? Yes : No} + + + + + {topup.amount.toFixed(2)} + + MVR + +
+ ))} +
+ + + + {meta?.total === 1 ? ( +

+ Total {meta?.total} topup. +

+ ) : ( +

+ Total {meta?.total} topups. +

+ )} +
+
+
+
+ +
+
+ {data.map((topup) => ( + + ))} +
+ + + )} +
+ ); +} + +function MobileTopupDetails({ topup }: { topup: Topup }) { + return ( +
+
+ + + {new Date(topup.created_at).toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + })} + +
+ +
+ + + + + {topup.paid ? "Paid" : "Unpaid"} + +
+
+
+

Amount

+ + {topup.amount.toFixed(2)} MVR + +
+
+
+ ); +} diff --git a/components/wallet.tsx b/components/wallet.tsx index bb9526d..d41ec28 100644 --- a/components/wallet.tsx +++ b/components/wallet.tsx @@ -1,4 +1,11 @@ "use client"; +import { useAtom } from "jotai"; +import { CircleDollarSign, Loader2, Wallet2 } from "lucide-react"; +import millify from "millify"; +import { usePathname, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { createTopup } from "@/actions/payment"; import { Button } from "@/components/ui/button"; import { Drawer, @@ -12,12 +19,6 @@ import { } from "@/components/ui/drawer"; import { WalletDrawerOpenAtom, walletTopUpValue } from "@/lib/atoms"; import type { TopupType } from "@/lib/types"; -import { useAtom } from "jotai"; -import { CircleDollarSign, Loader2, Wallet2 } from "lucide-react"; -import millify from "millify"; -import { useSession } from "next-auth/react"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; import NumberInput from "./number-input"; export function Wallet({ @@ -25,21 +26,18 @@ export function Wallet({ }: { walletBalance: number; }) { - const session = useSession(); const pathname = usePathname(); const [amount, setAmount] = useAtom(walletTopUpValue); const [isOpen, setIsOpen] = useAtom(WalletDrawerOpenAtom); const [disabled, setDisabled] = useState(false); - // const router = useRouter(); + const router = useRouter(); if (pathname === "/payment") { return null; } const data: TopupType = { - userId: session?.data?.user?.id ?? "", amount: Number.parseFloat(amount.toFixed(2)), - paid: false, }; return ( @@ -85,23 +83,20 @@ export function Wallet({ onClick={async () => { console.log(data); setDisabled(true); - // const payment = await createPayment(data) + const topup = await createTopup(data) setDisabled(false); - // setMonths(1) - // if (payment) { - // router.push(`/payments/${payment.id}`); - // setIsOpen(!isOpen); - // } else { - // toast.error("Something went wrong.") - // } + if (topup) { + router.push(`/top-ups/${topup.id}`); + setIsOpen(!isOpen); + } else { + toast.error("Something went wrong.") + } }} className="w-full" disabled={amount === 0 || disabled} > {disabled ? ( - <> - - + ) : ( <> Go to payment