diff --git a/actions/payment.ts b/actions/payment.ts index e51afd6..dd40b30 100644 --- a/actions/payment.ts +++ b/actions/payment.ts @@ -4,6 +4,7 @@ import prisma from "@/lib/db"; import type { PaymentType } from "@/lib/types"; import { formatMacAddress } from "@/lib/utils"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { addDevicesToGroup } from "./omada-actions"; export async function createPayment(data: PaymentType) { @@ -28,77 +29,159 @@ export async function createPayment(data: PaymentType) { } type VerifyPaymentType = { + userId: string; paymentId?: string; benefName: string; accountNo?: string; absAmount: string; time: string; + type?: "TRANSFER" | "WALLET"; }; -export async function verifyPayment(data: VerifyPaymentType) { - console.log({ data }); - try { - const payment = await prisma.payment.findUnique({ - where: { - id: data.paymentId, - }, - include: { - devices: true, - }, - }); - const response = await fetch( - "https://verifypaymentsapi.baraveli.dev/verify-payment", - { - method: "POST", - headers: { - "Content-Type": "application/json", +type PaymentWithDevices = { + id: string; + devices: Array<{ + name: string; + mac: string; + }>; +}; + +class InsufficientFundsError extends Error { + constructor() { + super("Insufficient funds in wallet"); + this.name = "InsufficientFundsError"; + } +} + +async function processWalletPayment( + user: { id: string; walletBalance: number } | null, + payment: PaymentWithDevices | null, + amount: number, +) { + if (!user || !payment) { + throw new Error("User or payment not found"); + } + + const walletBalance = user.walletBalance ?? 0; + if (walletBalance < amount) { + throw new InsufficientFundsError(); + } + + await prisma.$transaction([ + prisma.payment.update({ + where: { id: payment.id }, + data: { + paid: true, + paidAt: new Date(), + devices: { + updateMany: { + where: { paymentId: payment.id }, + data: { isActive: true }, + }, }, - body: JSON.stringify(data), }, - ); - const json = await response.json(); - console.log(json); - const newDevices = payment?.devices.map((d) => { - return { - name: d.name, - macAddress: formatMacAddress(d.mac), + }), + prisma.user.update({ + where: { id: user.id }, + data: { walletBalance: walletBalance - amount }, + }), + ]); +} + +type VerifyPaymentResponse = + | { + success: boolean; + message: string; + } + | { + success: boolean; + message: string; + transaction: { + ref: string; + sourceBank: string; + trxDate: string; }; + }; + +async function verifyExternalPayment( + data: VerifyPaymentType, + payment: PaymentWithDevices | null, +): Promise { + const response = await fetch( + "https://verifypaymentsapi.baraveli.dev/verify-payment", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }, + ); + + const json = await response.json(); + + if (!payment) { + throw new Error("Payment verification failed or payment not found"); + } + + if (json.success) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { + paid: true, + paidAt: new Date(), + devices: { + updateMany: { + where: { paymentId: payment.id }, + data: { isActive: true }, + }, + }, + }, }); - if (json.success === true) { - await Promise.all([ - prisma.payment.update({ - where: { - id: payment?.id, - }, - data: { - paid: true, - paidAt: new Date(), - devices: { - updateMany: { - where: { - paymentId: payment?.id, - }, - data: { - isActive: true, - }, - }, - }, - }, - }), - ]); + } + + return json; +} + +async function updateDevices(payment: PaymentWithDevices | null) { + if (!payment) return; + + const newDevices = payment.devices.map((d) => ({ + name: d.name, + macAddress: formatMacAddress(d.mac), + })); + + return await addDevicesToGroup({ + groupId: process.env.OMADA_GROUP_ID, + siteId: process.env.OMADA_SITE_ID, + newDevices, + }); +} + +export async function verifyPayment(data: VerifyPaymentType) { + try { + const [payment, user] = await Promise.all([ + prisma.payment.findUnique({ + where: { id: data.paymentId }, + include: { devices: true }, + }), + prisma.user.findUnique({ + where: { id: data.userId }, + }), + ]); + + if (data.type === "WALLET") { + await processWalletPayment(user, payment, Number(data.absAmount)); + redirect("/payments"); } - const res = await addDevicesToGroup({ - groupId: process.env.OMADA_GROUP_ID, - siteId: process.env.OMADA_SITE_ID, - newDevices: newDevices || [], - }); + const verificationResult = await verifyExternalPayment(data, payment); + await updateDevices(payment); revalidatePath("/payment[paymentId]"); - console.log(res); - return res; + + return verificationResult; } catch (error) { - console.error(error); + console.error("Payment verification failed:", error); + throw error; // Re-throw to handle at a higher level } } diff --git a/app/(dashboard)/devices/page.tsx b/app/(dashboard)/devices/page.tsx index 3380287..143e462 100644 --- a/app/(dashboard)/devices/page.tsx +++ b/app/(dashboard)/devices/page.tsx @@ -1,23 +1,10 @@ import { DevicesTable } from "@/components/devices-table"; -import Filter from "@/components/filter"; import Search from "@/components/search"; import AddDeviceDialogForm from "@/components/user/add-device-dialog"; import { getCurrentUser } from "@/lib/auth-utils"; -import { AArrowDown, AArrowUp } from "lucide-react"; import React, { Suspense } from "react"; -const sortfilterOptions = [ - { - value: 'asc', - label: 'Ascending', - icon: , - }, - { - value: 'desc', - label: 'Descending', - icon: , - }, -] + export default async function Devices({ @@ -46,11 +33,7 @@ export default async function Devices({ className=" border-b-2 pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start" > - + diff --git a/app/(dashboard)/parental-control/page.tsx b/app/(dashboard)/parental-control/page.tsx index e307cb6..fa6ad43 100644 --- a/app/(dashboard)/parental-control/page.tsx +++ b/app/(dashboard)/parental-control/page.tsx @@ -1,21 +1,8 @@ import { DevicesTable } from "@/components/devices-table"; -import Filter from "@/components/filter"; import Search from "@/components/search"; -import { AArrowDown, AArrowUp } from "lucide-react"; -import React, { Suspense } from "react"; +import { Suspense } from "react"; + -const sortfilterOptions = [ - { - value: 'asc', - label: 'Ascending', - icon: , - }, - { - value: 'desc', - label: 'Descending', - icon: , - }, -] export default async function ParentalControl({ @@ -42,11 +29,7 @@ export default async function ParentalControl({ className=" border-b-2 pb-4 gap-4 flex sm:flex-row flex-col items-start justify-start" > - + diff --git a/app/api/check-devices/route.ts b/app/api/check-devices/route.ts index 6735911..8fab37d 100644 --- a/app/api/check-devices/route.ts +++ b/app/api/check-devices/route.ts @@ -70,6 +70,11 @@ export async function GET(request: Request) { // 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: { @@ -79,10 +84,6 @@ export async function GET(request: Request) { }); devicesBlocked++; } - await blockDevice({ - macAddress: device.mac, - type: "block", - }); } if (hoursSinceLastRun < 24) { diff --git a/components/add-devices-to-cart-button.tsx b/components/add-devices-to-cart-button.tsx index eccd295..db5fdd5 100644 --- a/components/add-devices-to-cart-button.tsx +++ b/components/add-devices-to-cart-button.tsx @@ -11,6 +11,7 @@ export default function AddDevicesToCartButton({ device }: { device: Device }) { const devices = useAtomValue(deviceCartAtom) return ( diff --git a/components/auth/application-layout.tsx b/components/auth/application-layout.tsx index 147a255..b658c13 100644 --- a/components/auth/application-layout.tsx +++ b/components/auth/application-layout.tsx @@ -1,4 +1,6 @@ import { DeviceCartDrawer } from "@/components/device-cart"; +import { Wallet } from "@/components/wallet"; + import { ModeToggle } from "@/components/theme-toggle"; import { AppSidebar } from "@/components/ui/app-sidebar"; @@ -20,7 +22,11 @@ export async function ApplicationLayout({ headers: await headers() }); const billFormula = await prisma.billFormula.findFirst(); - + const user = await prisma.user.findFirst({ + where: { + id: session?.user?.id, + }, + }); return ( @@ -32,6 +38,7 @@ export async function ApplicationLayout({
+ diff --git a/components/block-device-button.tsx b/components/block-device-dialog.tsx similarity index 92% rename from components/block-device-button.tsx rename to components/block-device-dialog.tsx index b9e2522..95e5d24 100644 --- a/components/block-device-button.tsx +++ b/components/block-device-dialog.tsx @@ -7,10 +7,11 @@ import { useState } from "react"; import { toast } from "sonner"; import { TextShimmer } from "./ui/text-shimmer"; -export default function BlockDeviceButton({ device }: { device: Device }) { +export default function BlockDeviceDialog({ device }: { device: Device }) { const [disabled, setDisabled] = useState(false); return ( @@ -117,7 +117,7 @@ export function DeviceCartDrawer({ setMonths(1) if (payment) { router.push(`/payments/${payment.id}`); - setIsOpen(!isOpen); + setTimeout(() => setIsOpen(!isOpen), 2000); } else { toast.error("Something went wrong.") } diff --git a/components/devices-table.tsx b/components/devices-table.tsx index 205e999..65c2a73 100644 --- a/components/devices-table.tsx +++ b/components/devices-table.tsx @@ -13,7 +13,8 @@ import prisma from "@/lib/db"; import { headers } from "next/headers"; import Link from "next/link"; import AddDevicesToCartButton from "./add-devices-to-cart-button"; -import BlockDeviceButton from "./block-device-button"; +import BlockDeviceButton from "./block-device-dialog"; +import DeviceCard from "./device-card"; import Pagination from "./pagination"; export async function DevicesTable({ @@ -56,6 +57,7 @@ export async function DevicesTable({ } }, isActive: parentalControl ? parentalControl : undefined, + blocked: parentalControl !== undefined ? undefined : false, }, }); @@ -86,6 +88,8 @@ export async function DevicesTable({ } }, isActive: parentalControl, + blocked: parentalControl !== undefined ? undefined : false, + }, skip: offset, @@ -103,65 +107,82 @@ export async function DevicesTable({
) : ( <> - - Table of all devices. - - - Device Name - MAC Address - Actions - - - - {devices.map((device) => ( - - -
- - {device.name} - - - Active until{" "} - {new Date().toLocaleDateString("en-US", { - month: "short", - day: "2-digit", - year: "numeric", - })} - -
-
- {device.mac} - - {!parentalControl ? ( - - ) : ( - +
+
+ Table of all devices. + + + Device Name + MAC Address + Actions + + + + {devices.map((device) => ( + + +
+ + {device.name} + + + Active until{" "} + {new Date().toLocaleDateString("en-US", { + month: "short", + day: "2-digit", + year: "numeric", + })} + + {parentalControl && ( +
+ Comment: +

+ blocked because he was watching youtube +

+
+ )} + +
+
+ {device.mac} + + {!parentalControl ? ( + + ) : ( + + )} + +
+ ))} +
+ + + + {query.length > 0 && ( +

+ Showing {devices.length} locations for "{query} + " +

)}
+ + {totalDevices} devices +
- ))} - - - - - {query.length > 0 && ( -

- Showing {devices.length} locations for "{query} - " -

- )} -
- - {totalDevices} devices - -
-
-
- + + + + +
+ {devices.map((device) => ( + + ))} +
+ )} ); diff --git a/components/devices-to-pay.tsx b/components/devices-to-pay.tsx index 23b1513..f249e9f 100644 --- a/components/devices-to-pay.tsx +++ b/components/devices-to-pay.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/table"; import { formatDate } from "@/lib/utils"; import type { BillFormula, Prisma, User } from "@prisma/client"; -import { Clipboard, ClipboardCheck, Loader2, Wallet } from "lucide-react"; +import { BadgeDollarSign, Clipboard, ClipboardCheck, Loader2, Wallet } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "./ui/button"; @@ -36,7 +36,8 @@ export default function DevicesToPay({ const discountPercentage = billFormula?.discountPercentage ?? 75; // 100+(n−1)×75 const total = baseAmount + (devices?.length ?? 1 - 1) * discountPercentage; - + const walletBalance = user?.walletBalance ?? 0; + const isWalletPayVisible = walletBalance > total; return ( @@ -73,33 +74,59 @@ export default function DevicesToPay({ {payment?.paid ? ( ) : ( - +
+ {isWalletPayVisible && ( + + )} + +
+ )} diff --git a/components/search.tsx b/components/search.tsx index 75db22c..21c7a66 100644 --- a/components/search.tsx +++ b/components/search.tsx @@ -1,10 +1,9 @@ "use client"; import { Input } from "@/components/ui/input"; -import { Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useRef, useTransition } from "react"; -import { Button } from "./ui/button"; export default function Search({ disabled }: { disabled?: boolean }) { const inputRef = useRef(null); @@ -31,30 +30,17 @@ export default function Search({ disabled }: { disabled?: boolean }) { } return ( -
- handleSearch(e.target.value)} - /> - -
+ handleSearch(e.target.value)} + /> ); } diff --git a/components/ui/app-sidebar.tsx b/components/ui/app-sidebar.tsx index 162c707..c373dda 100644 --- a/components/ui/app-sidebar.tsx +++ b/components/ui/app-sidebar.tsx @@ -7,6 +7,7 @@ import { MonitorSpeaker, Smartphone, UsersRound, + Wallet2Icon, } from "lucide-react"; import { @@ -55,6 +56,11 @@ const data = { url: "/agreements", icon: , }, + { + title: "Wallet", + url: "/wallet", + icon: , + }, ], }, { diff --git a/components/wallet.tsx b/components/wallet.tsx new file mode 100644 index 0000000..6915285 --- /dev/null +++ b/components/wallet.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { + WalletDrawerOpenAtom, + walletTopUpValue, +} from "@/lib/atoms"; +import { authClient } from "@/lib/auth-client"; +import type { TopupType } from "@/lib/types"; +import { useAtom, } from "jotai"; +import { + CircleDollarSign, + Loader2, + Wallet2, +} from "lucide-react"; +import { usePathname, } from "next/navigation"; +import { useState } from "react"; +import NumberInput from "./number-input"; + + + +export function Wallet({ + walletBalance, +}: { + walletBalance: number; +}) { + const session = authClient.useSession(); + const pathname = usePathname(); + const [amount, setAmount] = useAtom(walletTopUpValue); + const [isOpen, setIsOpen] = useAtom(WalletDrawerOpenAtom); + const [disabled, setDisabled] = useState(false); + // const router = useRouter(); + + if (pathname === "/payment") { + return null; + } + + const data: TopupType = { + userId: session?.data?.user.id ?? "", + amount: Number.parseFloat(amount.toFixed(2)), + paid: false, + }; + + return ( + + + + + +
+ + Wallet + +
+ Your wallet balance is{" "} + + {walletBalance.toFixed(2)} + {" "} +
+
+
+ +
+ setAmount(value)} + maxAllowed={5000} + isDisabled={amount === 0} + /> + +
+ + + + + + +
+
+
+ ); +} + diff --git a/lib/atoms.ts b/lib/atoms.ts index 620d532..c927ef0 100644 --- a/lib/atoms.ts +++ b/lib/atoms.ts @@ -10,9 +10,12 @@ export const discountPercentageAtom = atom(75); export const numberOfDevicesAtom = atom(1); export const numberOfDaysAtom = atom(30); export const numberOfMonths = atom(1); +export const walletTopUpValue = atom(1); export const formulaResultAtom = atom(""); export const deviceCartAtom = atom([]); export const cartDrawerOpenAtom = atom(false); +export const WalletDrawerOpenAtom = atom(false); + // Export the atoms with their store export const atoms = { initialPriceAtom, @@ -23,4 +26,5 @@ export const atoms = { formulaResultAtom, deviceCartAtom, cartDrawerOpenAtom, + walletTopUpValue, }; diff --git a/lib/types.ts b/lib/types.ts index c76e977..a399673 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -6,6 +6,12 @@ export type PaymentType = { paid: boolean; }; +export type TopupType = { + amount: number; + userId: string; + paid: boolean; +}; + interface IpAddress { ip: string; mask: number; diff --git a/prisma/migrations/20241224110841_add/migration.sql b/prisma/migrations/20241224110841_add/migration.sql new file mode 100644 index 0000000..c878d68 --- /dev/null +++ b/prisma/migrations/20241224110841_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "walletBalance" DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20241224111353_add/migration.sql b/prisma/migrations/20241224111353_add/migration.sql new file mode 100644 index 0000000..2a8bc5e --- /dev/null +++ b/prisma/migrations/20241224111353_add/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Device" ADD COLUMN "reasonForBlocking" TEXT; diff --git a/prisma/migrations/20241224145258_add/migration.sql b/prisma/migrations/20241224145258_add/migration.sql new file mode 100644 index 0000000..2890eca --- /dev/null +++ b/prisma/migrations/20241224145258_add/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "Topup" ( + "id" TEXT NOT NULL, + "amount" DOUBLE PRECISION NOT NULL, + "userId" TEXT NOT NULL, + "paid" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Topup_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c15ecd..e62cce1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { phoneNumberVerified Boolean @default(false) termsAccepted Boolean @default(false) policyAccepted Boolean @default(false) + walletBalance Float @default(0) devices Device[] @@ -110,19 +111,20 @@ model Island { } model Device { - id String @id @default(cuid()) - name String - mac String - isActive Boolean @default(false) - registered Boolean @default(false) - blocked Boolean @default(false) - expiryDate DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - User User? @relation(fields: [userId], references: [id]) - userId String? - payment Payment? @relation(fields: [paymentId], references: [id]) - paymentId String? + id String @id @default(cuid()) + name String + mac String + reasonForBlocking String? + isActive Boolean @default(false) + registered Boolean @default(false) + blocked Boolean @default(false) + expiryDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + User User? @relation(fields: [userId], references: [id]) + userId String? + payment Payment? @relation(fields: [paymentId], references: [id]) + paymentId String? } model Payment { @@ -147,3 +149,12 @@ model BillFormula { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Topup { + id String @id @default(cuid()) + amount Float + userId String + paid Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +}