Enhance payment processing and device management features

- Introduced wallet payment option in verifyPayment function to allow users to pay using their wallet balance.
- Added new BlockDeviceDialog component for managing device blocking and unblocking actions.
- Updated DeviceCard component to display device status and integrate blocking functionality.
- Refactored DevicesTable to utilize DeviceCard for better UI representation of devices.
- Implemented Wallet component to manage wallet balance and top-up functionality.
- Enhanced API routes and Prisma schema to support wallet transactions and device blocking reasons.
- Improved overall user experience with responsive design adjustments and new UI elements.

These changes improve user control over payments and device management, enhancing the overall functionality of the application.
This commit is contained in:
2024-12-25 17:21:04 +05:00
parent c06c4fee3f
commit 75ad431160
21 changed files with 536 additions and 228 deletions

View File

@ -11,6 +11,7 @@ export default function AddDevicesToCartButton({ device }: { device: Device }) {
const devices = useAtomValue(deviceCartAtom)
return (
<Button
className='w-full mt-2'
disabled={devices.some((d) => d.id === device.id)}
onClick={() => setDeviceCart((prev) => [...prev, device])}
>

View File

@ -24,7 +24,7 @@ export function AccountPopover() {
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<Button className="w-fit px-2" variant="outline">
<UserIcon />
</Button>
</PopoverTrigger>

View File

@ -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 (
<SidebarProvider>
<AppSidebar role={session?.user?.role || "USER"} />
@ -32,6 +38,7 @@ export async function ApplicationLayout({
</div>
<div className="flex items-center gap-2">
<Wallet walletBalance={user?.walletBalance || 0} />
<DeviceCartDrawer billFormula={billFormula || null} />
<ModeToggle />
<AccountPopover />

View File

@ -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 (
<Button
className="w-full mt-2"
disabled={disabled}
onClick={() => {
setDisabled(true);

View File

@ -0,0 +1,49 @@
import type { Device } from '@prisma/client'
import Link from 'next/link'
import AddDevicesToCartButton from './add-devices-to-cart-button'
import BlockDeviceDialog from './block-device-dialog'
import { Badge } from './ui/badge'
export default function DeviceCard({ device, parentalControl }: { device: Device, parentalControl?: boolean }) {
return (
<div>
<div className="flex text-sm shadow flex-col items-start p-2 border rounded border-dashed">
<div className="font-semibold flex gap-2 mb-2">
<Link
className="font-medium hover:underline"
href={`/devices/${device.id}`}
>
{device.name}
</Link>
<Badge variant={"outline"}>
<span className="font-medium">
{device.mac}
</span>
</Badge>
</div>
<span className="text-muted-foreground">
Active until{" "}
{new Date().toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
{device.blocked && (
<div className="p-2 rounded border my-2 w-full">
<span>Comment: </span>
<p className="text-neutral-500">
blocked because he was watching youtube
</p>
</div>
)}
{!parentalControl ? (
<AddDevicesToCartButton device={device} />
) : (
<BlockDeviceDialog device={device} />
)}
</div>
</div>
)
}

View File

@ -79,7 +79,7 @@ export function DeviceCartDrawer({
<DrawerTrigger asChild>
<Button onClick={() => setIsOpen(!isOpen)} variant="outline">
<MonitorSmartphone />
Selected Devices {devices.length > 0 && `(${devices.length})`}
{devices.length > 0 && `(${devices.length})`}
</Button>
</DrawerTrigger>
<DrawerContent>
@ -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.")
}

View File

@ -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({
</div>
) : (
<>
<Table className="overflow-scroll">
<TableCaption>Table of all devices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Device Name</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell>
<div className="flex flex-col items-start">
<Link
className="font-medium hover:underline"
href={`/devices/${device.id}`}
>
{device.name}
</Link>
<span className="text-muted-foreground">
Active until{" "}
{new Date().toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
</div>
</TableCell>
<TableCell className="font-medium">{device.mac}</TableCell>
<TableCell>
{!parentalControl ? (
<AddDevicesToCartButton device={device} />
) : (
<BlockDeviceButton device={device} />
<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>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody className="overflow-scroll">
{devices.map((device) => (
<TableRow key={device.id}>
<TableCell>
<div className="flex flex-col items-start">
<Link
className="font-medium hover:underline"
href={`/devices/${device.id}`}
>
{device.name}
</Link>
<span className="text-muted-foreground">
Active until{" "}
{new Date().toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
})}
</span>
{parentalControl && (
<div className="p-2 rounded border my-2">
<span>Comment: </span>
<p className="text-neutral-500">
blocked because he was watching youtube
</p>
</div>
)}
</div>
</TableCell>
<TableCell className="font-medium">{device.mac}</TableCell>
<TableCell>
{!parentalControl ? (
<AddDevicesToCartButton device={device} />
) : (
<BlockDeviceButton device={device} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>
{query.length > 0 && (
<p className="text-sm text-muted-foreground">
Showing {devices.length} locations for &quot;{query}
&quot;
</p>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{totalDevices} devices
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>
{query.length > 0 && (
<p className="text-sm text-muted-foreground">
Showing {devices.length} locations for &quot;{query}
&quot;
</p>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{totalDevices} devices
</TableCell>
</TableRow>
</TableFooter>
</Table>
<Pagination totalPages={totalPages} currentPage={page} />
</TableFooter>
</Table>
<Pagination totalPages={totalPages} currentPage={page} />
</div>
<div className="sm:hidden my-4">
{devices.map((device) => (
<DeviceCard parentalControl={parentalControl} key={device.id} device={device} />
))}
</div>
</>
)}
</div>
);

View File

@ -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+(n1)×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 ? (
<Button size={"lg"} variant={"secondary"} disabled className="dark:text-green-200 text-green-900 bg-green-500/20 uppercase font-semibold">Payment Verified</Button>
) : (
<Button
disabled={verifying}
onClick={async () => {
setVerifying(true);
await verifyPayment({
paymentId: payment?.id,
benefName: user?.name ?? "",
accountNo: user?.accNo ?? "",
absAmount: String(total),
time: formatDate(new Date(payment?.createdAt || "")),
});
setVerifying(false);
// switch (true) {
// case res?.success === true:
// toast.success(res.message);
// break;
// case res.success === false:
// toast.error(res.message);
// break;
// default:
// toast.error("Unexpected error occurred.");
// }
}}
size={"lg"} className="mb-4">
{verifying ? "Verifying..." : "Verify Payment"}
{verifying ? <Loader2 className="animate-spin" /> : <Wallet />}
</Button>
<div className="flex flex-col gap-2">
{isWalletPayVisible && (
<Button
disabled={verifying}
onClick={async () => {
setVerifying(true);
await verifyPayment({
userId: user?.id ?? "",
paymentId: payment?.id,
benefName: user?.name ?? "",
accountNo: user?.accNo ?? "",
absAmount: String(total),
time: formatDate(new Date(payment?.createdAt || "")),
type: "WALLET",
});
setVerifying(false);
}}
variant={"secondary"} size={"lg"}>
{verifying ? "Paying..." : "Pay with wallet"}
<Wallet />
</Button>
)}
<Button
disabled={verifying}
onClick={async () => {
setVerifying(true);
const res = await verifyPayment({
userId: user?.id ?? "",
paymentId: payment?.id,
benefName: user?.name ?? "",
accountNo: user?.accNo ?? "",
absAmount: String(total),
type: "TRANSFER",
time: formatDate(new Date(payment?.createdAt || "")),
});
setVerifying(false);
switch (true) {
case res?.success === true:
toast.success(res.message);
break;
case res.success === false:
toast.error(res.message);
break;
default:
toast.error("Unexpected error occurred.");
}
}}
size={"lg"} className="mb-4">
{verifying ? "Verifying..." : "Verify Payment"}
{verifying ? <Loader2 className="animate-spin" /> : <BadgeDollarSign />}
</Button>
</div>
)}
</div>

View File

@ -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<HTMLInputElement>(null);
@ -31,30 +30,17 @@ export default function Search({ disabled }: { disabled?: boolean }) {
}
return (
<div className="flex w-full gap-2 items-center justify-between">
<Input
ref={inputRef}
placeholder="Search..."
type="text"
className="w-fit"
name="search"
id="search"
defaultValue={searchQuery ? searchQuery : ""}
disabled={disabled}
spellCheck={false}
onChange={(e) => handleSearch(e.target.value)}
/>
<Button
disabled={isPending}
onClick={() => {
if (inputRef.current) {
inputRef.current.value = "";
}
replace(pathname);
}}
>
{isPending ? <Loader2 className="animate-spin" /> : "Reset"}
</Button>
</div>
<Input
ref={inputRef}
placeholder="Search..."
type="search"
className={cn("w-fit", isPending && "animate-pulse")}
name="search"
id="search"
defaultValue={searchQuery ? searchQuery : ""}
disabled={disabled}
spellCheck={false}
onChange={(e) => handleSearch(e.target.value)}
/>
);
}

View File

@ -7,6 +7,7 @@ import {
MonitorSpeaker,
Smartphone,
UsersRound,
Wallet2Icon,
} from "lucide-react";
import {
@ -55,6 +56,11 @@ const data = {
url: "/agreements",
icon: <Handshake size={16} />,
},
{
title: "Wallet",
url: "/wallet",
icon: <Wallet2Icon size={16} />,
},
],
},
{

124
components/wallet.tsx Normal file
View File

@ -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 (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerTrigger asChild>
<Button onClick={() => setIsOpen(!isOpen)} variant="outline">
{walletBalance} MVR
<Wallet2 />
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Wallet</DrawerTitle>
<DrawerDescription asChild>
<div>
Your wallet balance is{" "}
<span className="font-semibold">
{walletBalance.toFixed(2)}
</span>{" "}
</div>
</DrawerDescription>
</DrawerHeader>
<div className="px-4 flex flex-col gap-4">
<NumberInput
label="Set amount to top up"
value={amount}
onChange={(value) => setAmount(value)}
maxAllowed={5000}
isDisabled={amount === 0}
/>
</div>
<DrawerFooter>
<Button
onClick={async () => {
console.log(data)
setDisabled(true)
// const payment = await createPayment(data)
setDisabled(false)
// setMonths(1)
// if (payment) {
// router.push(`/payments/${payment.id}`);
// setIsOpen(!isOpen);
// } else {
// toast.error("Something went wrong.")
// }
}}
className="w-full"
disabled={amount === 0 || disabled}
>
{disabled ? (
<>
<Loader2 className="ml-2 animate-spin" />
</>
) : (
<>
Go to payment
<CircleDollarSign />
</>
)}
</Button>
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}