Dental Care
This commit is contained in:
883
components/admin/appointments-table.tsx
Normal file
883
components/admin/appointments-table.tsx
Normal file
@@ -0,0 +1,883 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { Calendar, Clock, User, Mail } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
confirmAppointments,
|
||||
cancelAppointments,
|
||||
completeAppointments,
|
||||
deleteAppointments,
|
||||
deleteAppointment,
|
||||
} from "@/lib/actions/admin-actions";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Appointment = {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
patient: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
dentist: {
|
||||
name: string;
|
||||
};
|
||||
service: {
|
||||
name: string;
|
||||
price: string;
|
||||
};
|
||||
payment: {
|
||||
status: string;
|
||||
amount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
paid: "default",
|
||||
pending: "secondary",
|
||||
failed: "destructive",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Appointment>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "patientName",
|
||||
accessorFn: (row) => row.patient.name,
|
||||
header: "Patient",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.patient.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.original.patient.email}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "dentistName",
|
||||
accessorFn: (row) => row.dentist.name,
|
||||
header: "Dentist",
|
||||
cell: ({ row }) => <span>Dr. {row.original.dentist.name}</span>,
|
||||
},
|
||||
{
|
||||
id: "serviceName",
|
||||
accessorFn: (row) => row.service.name,
|
||||
header: "Service",
|
||||
cell: ({ row }) => row.original.service.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date & Time",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p>{new Date(row.original.date).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.timeSlot}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => getStatusBadge(row.original.status),
|
||||
},
|
||||
{
|
||||
id: "paymentStatus",
|
||||
accessorFn: (row) => row.payment?.status || "none",
|
||||
header: "Payment",
|
||||
cell: ({ row }) =>
|
||||
row.original.payment ? getPaymentBadge(row.original.payment.status) : "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: () => <div className="text-right">Amount</div>,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.payment) {
|
||||
const amount = row.original.payment.amount;
|
||||
return <div className="text-right">₱{amount.toFixed(2)}</div>;
|
||||
}
|
||||
// service.price is a string (e.g., "₱500 – ₱1,500" or "₱1,500")
|
||||
const price = row.original.service.price;
|
||||
return <div className="text-right">{price}</div>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type AdminAppointmentsTableProps = {
|
||||
appointments: Appointment[];
|
||||
};
|
||||
|
||||
export function AdminAppointmentsTable({
|
||||
appointments,
|
||||
}: AdminAppointmentsTableProps) {
|
||||
const router = useRouter();
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [selectedAppointment, setSelectedAppointment] =
|
||||
React.useState<Appointment | null>(null);
|
||||
|
||||
const formatPrice = (price: number | string): string => {
|
||||
if (typeof price === "string") {
|
||||
return price;
|
||||
}
|
||||
if (isNaN(price)) {
|
||||
return "Contact for pricing";
|
||||
}
|
||||
return `₱${price.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const handleBulkAction = async (
|
||||
action: (ids: string[]) => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const ids = selectedRows.map((row) => row.original.id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error("No appointments selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action(ids);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleAction = async (
|
||||
action: () => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<Appointment> = {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedAppointment(row.original)}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Edit feature coming soon")}
|
||||
>
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Reschedule feature coming soon")}
|
||||
>
|
||||
Reschedule
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleSingleAction(
|
||||
() => cancelAppointments([row.original.id]),
|
||||
"cancel appointment"
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleSingleAction(
|
||||
() => deleteAppointment(row.original.id),
|
||||
"delete appointment"
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const columnsWithActions = [...columns, actionsColumn];
|
||||
|
||||
const table = useReactTable({
|
||||
data: appointments,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search appointments..."
|
||||
value={
|
||||
(table.getColumn("patientName")?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn("patientName")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() =>
|
||||
handleBulkAction(confirmAppointments, "confirm appointments")
|
||||
}
|
||||
>
|
||||
Confirm Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => toast.info("Reschedule feature coming soon")}
|
||||
>
|
||||
Reschedule Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => toast.info("Send reminders feature coming soon")}
|
||||
>
|
||||
Send Reminders
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isLoading}>
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleBulkAction(
|
||||
completeAppointments,
|
||||
"complete appointments"
|
||||
)
|
||||
}
|
||||
>
|
||||
Mark as Completed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Export feature coming soon")}
|
||||
>
|
||||
Export Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleBulkAction(cancelAppointments, "cancel appointments")
|
||||
}
|
||||
>
|
||||
Cancel Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleBulkAction(deleteAppointments, "delete appointments")
|
||||
}
|
||||
>
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No appointments found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedAppointment}
|
||||
onOpenChange={() => setSelectedAppointment(null)}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">
|
||||
Appointment Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Booking ID: {selectedAppointment?.id}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{selectedAppointment &&
|
||||
getStatusBadge(selectedAppointment.status)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedAppointment && (
|
||||
<div className="space-y-6">
|
||||
{/* Patient Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Patient Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Patient Name
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.patient.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="font-medium text-sm">
|
||||
{selectedAppointment.patient.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Service Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Service</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.service.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Price</p>
|
||||
<p className="font-medium">
|
||||
{formatPrice(selectedAppointment.service.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Schedule */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Appointment Schedule
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Date</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedAppointment.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Time</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentist Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Assigned Dentist
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Dentist</p>
|
||||
<p className="font-medium">
|
||||
Dr. {selectedAppointment.dentist.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
{selectedAppointment.payment && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Payment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Payment Status
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{getPaymentBadge(selectedAppointment.payment.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Amount</p>
|
||||
<p className="font-medium">
|
||||
₱{selectedAppointment.payment.amount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppointment.notes && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Special Requests / Notes
|
||||
</h3>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">
|
||||
{selectedAppointment.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{selectedAppointment.status === "pending" && (
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleSingleAction(
|
||||
() => confirmAppointments([id]),
|
||||
"confirm appointment"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Confirm Appointment
|
||||
</Button>
|
||||
)}
|
||||
{(selectedAppointment.status === "pending" ||
|
||||
selectedAppointment.status === "confirmed") && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedAppointment(null);
|
||||
toast.info("Reschedule feature coming soon");
|
||||
}}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleSingleAction(
|
||||
() => cancelAppointments([id]),
|
||||
"cancel appointment"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{selectedAppointment.status === "confirmed" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleSingleAction(
|
||||
() => completeAppointments([id]),
|
||||
"mark as completed"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Mark as Completed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
807
components/admin/dentists-table.tsx
Normal file
807
components/admin/dentists-table.tsx
Normal file
@@ -0,0 +1,807 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { User, Mail, Phone, Calendar, Award, Briefcase } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
updateDentistAvailability,
|
||||
deleteDentist,
|
||||
} from "@/lib/actions/admin-actions";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Dentist = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
specialization: string | null;
|
||||
qualifications: string | null;
|
||||
experience: string | null;
|
||||
isAvailable: boolean;
|
||||
createdAt: Date;
|
||||
appointmentsAsDentist: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Dentist>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">Dr. {row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.email}</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "specialization",
|
||||
header: "Specialization",
|
||||
cell: ({ row }) => row.original.specialization || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "experience",
|
||||
header: "Experience",
|
||||
cell: ({ row }) => row.original.experience || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "appointments",
|
||||
header: "Appointments",
|
||||
cell: ({ row }) => {
|
||||
const appointmentCount = row.original.appointmentsAsDentist.length;
|
||||
const completedCount = row.original.appointmentsAsDentist.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
).length;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p>{appointmentCount} total</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{completedCount} completed
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "isAvailable",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.isAvailable ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{row.original.isAvailable ? "Available" : "Unavailable"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Joined",
|
||||
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Details</DropdownMenuItem>
|
||||
<DropdownMenuItem>Edit Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>View Schedule</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Deactivate</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type AdminDentistsTableProps = {
|
||||
dentists: Dentist[];
|
||||
};
|
||||
|
||||
export function AdminDentistsTable({ dentists }: AdminDentistsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [selectedDentist, setSelectedDentist] = React.useState<Dentist | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleBulkAvailability = async (isAvailable: boolean) => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const ids = selectedRows.map((row) => row.original.id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error("No dentists selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateDentistAvailability(ids, isAvailable);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setRowSelection({});
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to update availability`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleAction = async (
|
||||
action: () => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<Dentist> = {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => setSelectedDentist(row.original)}>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Edit feature coming soon")}
|
||||
>
|
||||
Edit Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Schedule feature coming soon")}
|
||||
>
|
||||
View Schedule
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleSingleAction(
|
||||
() =>
|
||||
updateDentistAvailability(
|
||||
[row.original.id],
|
||||
!row.original.isAvailable
|
||||
),
|
||||
row.original.isAvailable ? "set unavailable" : "set available"
|
||||
)
|
||||
}
|
||||
>
|
||||
{row.original.isAvailable ? "Set Unavailable" : "Set Available"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this dentist?")) {
|
||||
handleSingleAction(
|
||||
() => deleteDentist(row.original.id),
|
||||
"delete dentist"
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const columnsWithActions = [...columns.slice(0, -1), actionsColumn];
|
||||
|
||||
const table = useReactTable({
|
||||
data: dentists,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search dentists..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => handleBulkAvailability(true)}
|
||||
>
|
||||
Set Available
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => handleBulkAvailability(false)}
|
||||
>
|
||||
Set Unavailable
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isLoading}>
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Send notification feature coming soon")
|
||||
}
|
||||
>
|
||||
Send Notification
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Export feature coming soon")}
|
||||
>
|
||||
Export Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Schedule feature coming soon")}
|
||||
>
|
||||
View Schedules
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to deactivate these dentists?"
|
||||
)
|
||||
) {
|
||||
handleBulkAvailability(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Deactivate Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No dentists found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentist Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedDentist}
|
||||
onOpenChange={() => setSelectedDentist(null)}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">Dentist Details</DialogTitle>
|
||||
<DialogDescription>ID: {selectedDentist?.id}</DialogDescription>
|
||||
</div>
|
||||
{selectedDentist && (
|
||||
<Badge
|
||||
variant={
|
||||
selectedDentist.isAvailable ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{selectedDentist.isAvailable ? "Available" : "Unavailable"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedDentist && (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Full Name</p>
|
||||
<p className="font-medium">Dr. {selectedDentist.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Mail className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="font-medium text-sm">
|
||||
{selectedDentist.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedDentist.phone && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Phone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Phone</p>
|
||||
<p className="font-medium">{selectedDentist.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Joined</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedDentist.createdAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Professional Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Professional Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedDentist.specialization && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Award className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Specialization
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{selectedDentist.specialization}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedDentist.experience && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Briefcase className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Experience
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{selectedDentist.experience}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedDentist.qualifications && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Qualifications
|
||||
</p>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">
|
||||
{selectedDentist.qualifications}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Appointment Statistics */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Appointment Statistics
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-2xl font-bold">
|
||||
{selectedDentist.appointmentsAsDentist.length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total Appointments
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-2xl font-bold">
|
||||
{
|
||||
selectedDentist.appointmentsAsDentist.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-2xl font-bold">
|
||||
{
|
||||
selectedDentist.appointmentsAsDentist.filter(
|
||||
(apt) =>
|
||||
apt.status === "pending" ||
|
||||
apt.status === "confirmed"
|
||||
).length
|
||||
}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Upcoming</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedDentist(null);
|
||||
toast.info("Edit feature coming soon");
|
||||
}}
|
||||
>
|
||||
Edit Profile
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedDentist(null);
|
||||
toast.info("Schedule feature coming soon");
|
||||
}}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedDentist.isAvailable ? "outline" : "default"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedDentist.id;
|
||||
const newStatus = !selectedDentist.isAvailable;
|
||||
setSelectedDentist(null);
|
||||
handleSingleAction(
|
||||
() => updateDentistAvailability([id], newStatus),
|
||||
newStatus ? "set available" : "set unavailable"
|
||||
);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{selectedDentist.isAvailable
|
||||
? "Set Unavailable"
|
||||
: "Set Available"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
437
components/admin/patients-table.tsx
Normal file
437
components/admin/patients-table.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
IconMail,
|
||||
IconPhone,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
|
||||
type Patient = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
dateOfBirth: Date | null;
|
||||
medicalHistory: string | null;
|
||||
createdAt: Date;
|
||||
appointmentsAsPatient: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
amount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Patient>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "contact",
|
||||
header: "Contact",
|
||||
cell: ({ row }) => (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<IconMail className="size-3" />
|
||||
<span className="text-xs">{row.original.email}</span>
|
||||
</div>
|
||||
{row.original.phone && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<IconPhone className="size-3" />
|
||||
<span className="text-xs">{row.original.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "dateOfBirth",
|
||||
header: "Date of Birth",
|
||||
cell: ({ row }) =>
|
||||
row.original.dateOfBirth
|
||||
? new Date(row.original.dateOfBirth).toLocaleDateString()
|
||||
: "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "appointments",
|
||||
header: "Appointments",
|
||||
cell: ({ row }) => row.original.appointmentsAsPatient.length,
|
||||
},
|
||||
{
|
||||
accessorKey: "totalSpent",
|
||||
header: () => <div className="text-right">Total Spent</div>,
|
||||
cell: ({ row }) => {
|
||||
const totalSpent = row.original.payments.reduce(
|
||||
(sum, payment) => sum + payment.amount,
|
||||
0
|
||||
);
|
||||
return <div className="text-right">₱{totalSpent.toFixed(2)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Joined",
|
||||
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Details</DropdownMenuItem>
|
||||
<DropdownMenuItem>Edit Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>View History</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type AdminPatientsTableProps = {
|
||||
patients: Patient[];
|
||||
};
|
||||
|
||||
export function AdminPatientsTable({ patients }: AdminPatientsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: patients,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search patients..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Send SMS
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Export Selected</DropdownMenuItem>
|
||||
<DropdownMenuItem>Add to Group</DropdownMenuItem>
|
||||
<DropdownMenuItem>Send Appointment Reminder</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No patients found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
572
components/admin/services-table.tsx
Normal file
572
components/admin/services-table.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
updateServiceStatus,
|
||||
deleteServices,
|
||||
deleteService,
|
||||
duplicateService,
|
||||
} from "@/lib/actions/admin-actions";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type Service = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
price: string;
|
||||
category: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
appointments: Array<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Service>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Service Name",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.name}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{row.original.description}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "category",
|
||||
header: "Category",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.category}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => `${row.original.duration} mins`,
|
||||
},
|
||||
{
|
||||
accessorKey: "price",
|
||||
header: () => <div className="text-right">Price</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-medium">
|
||||
{row.original.price || "N/A"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "bookings",
|
||||
header: "Bookings",
|
||||
cell: ({ row }) => row.original.appointments.length,
|
||||
},
|
||||
{
|
||||
accessorKey: "isActive",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={row.original.isActive ? "default" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{row.original.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type AdminServicesTableProps = {
|
||||
services: Service[];
|
||||
};
|
||||
|
||||
export function AdminServicesTable({ services }: AdminServicesTableProps) {
|
||||
const router = useRouter();
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
);
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const handleServiceAction = async (
|
||||
action: () => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAction = async (
|
||||
action: (ids: string[]) => Promise<{ success: boolean; message: string }>,
|
||||
actionName: string
|
||||
) => {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
const ids = selectedRows.map((row) => row.original.id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
toast.error("No services selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await action(ids);
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setRowSelection({});
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to ${actionName}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionsColumn: ColumnDef<Service> = {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Edit feature coming soon")}
|
||||
>
|
||||
Edit Service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleServiceAction(
|
||||
() => duplicateService(row.original.id),
|
||||
"duplicate service"
|
||||
)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleServiceAction(
|
||||
() =>
|
||||
updateServiceStatus(
|
||||
[row.original.id],
|
||||
!row.original.isActive
|
||||
),
|
||||
"toggle service status"
|
||||
)
|
||||
}
|
||||
>
|
||||
{row.original.isActive ? "Deactivate" : "Activate"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleServiceAction(
|
||||
() => deleteService(row.original.id),
|
||||
"delete service"
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
|
||||
const columnsWithActions = [...columns, actionsColumn];
|
||||
|
||||
const table = useReactTable({
|
||||
data: services,
|
||||
columns: columnsWithActions,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search services..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("name")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{table.getFilteredSelectedRowModel().rows.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
|
||||
<span className="text-sm font-medium">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() =>
|
||||
handleBulkAction(
|
||||
(ids) => updateServiceStatus(ids, true),
|
||||
"activate services"
|
||||
)
|
||||
}
|
||||
>
|
||||
Activate Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() =>
|
||||
handleBulkAction(
|
||||
(ids) => updateServiceStatus(ids, false),
|
||||
"deactivate services"
|
||||
)
|
||||
}
|
||||
>
|
||||
Deactivate Selected
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isLoading}>
|
||||
More Actions
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Duplicate feature coming soon")}
|
||||
>
|
||||
Duplicate Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
toast.info("Update prices feature coming soon")
|
||||
}
|
||||
>
|
||||
Update Prices
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toast.info("Export feature coming soon")}
|
||||
>
|
||||
Export Selected
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
handleBulkAction(deleteServices, "delete services")
|
||||
}
|
||||
>
|
||||
Delete Selected
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No services found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
749
components/admin/settings-content.tsx
Normal file
749
components/admin/settings-content.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
IconBell,
|
||||
IconBriefcase,
|
||||
IconKey,
|
||||
IconShield,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSettings,
|
||||
} from "@/lib/actions/settings-actions";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type AdminSettingsContentProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export function AdminSettingsContent({}: AdminSettingsContentProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// General Settings State
|
||||
const [clinicName, setClinicName] = React.useState("Dental U-Care");
|
||||
const [clinicEmail, setClinicEmail] = React.useState("info@dentalucare.com");
|
||||
const [clinicPhone, setClinicPhone] = React.useState("+1 (555) 123-4567");
|
||||
const [clinicAddress, setClinicAddress] = React.useState(
|
||||
"123 Medical Plaza, Suite 100"
|
||||
);
|
||||
const [timezone, setTimezone] = React.useState("America/New_York");
|
||||
|
||||
// Appointment Settings State
|
||||
const [appointmentDuration, setAppointmentDuration] = React.useState("60");
|
||||
const [bufferTime, setBufferTime] = React.useState("15");
|
||||
const [maxAdvanceBooking, setMaxAdvanceBooking] = React.useState("90");
|
||||
const [cancellationDeadline, setCancellationDeadline] = React.useState("24");
|
||||
const [autoConfirmAppointments, setAutoConfirmAppointments] =
|
||||
React.useState(false);
|
||||
|
||||
// Notification Settings State
|
||||
const [emailNotifications, setEmailNotifications] = React.useState(true);
|
||||
const [smsNotifications, setSmsNotifications] = React.useState(true);
|
||||
const [appointmentReminders, setAppointmentReminders] = React.useState(true);
|
||||
const [reminderHoursBefore, setReminderHoursBefore] = React.useState("24");
|
||||
const [newBookingNotifications, setNewBookingNotifications] =
|
||||
React.useState(true);
|
||||
const [cancellationNotifications, setCancellationNotifications] =
|
||||
React.useState(true);
|
||||
|
||||
// Payment Settings State
|
||||
const [requirePaymentUpfront, setRequirePaymentUpfront] =
|
||||
React.useState(false);
|
||||
const [allowPartialPayment, setAllowPartialPayment] = React.useState(true);
|
||||
const [depositPercentage, setDepositPercentage] = React.useState("50");
|
||||
const [acceptCash, setAcceptCash] = React.useState(true);
|
||||
const [acceptCard, setAcceptCard] = React.useState(true);
|
||||
const [acceptEWallet, setAcceptEWallet] = React.useState(true);
|
||||
|
||||
// Security Settings State
|
||||
const [twoFactorAuth, setTwoFactorAuth] = React.useState(false);
|
||||
const [sessionTimeout, setSessionTimeout] = React.useState("60");
|
||||
const [passwordExpiry, setPasswordExpiry] = React.useState("90");
|
||||
const [loginAttempts, setLoginAttempts] = React.useState("5");
|
||||
|
||||
// Load settings on mount
|
||||
React.useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await getAdminSettings();
|
||||
if (settings) {
|
||||
setClinicName(settings.clinicName);
|
||||
setClinicEmail(settings.clinicEmail);
|
||||
setClinicPhone(settings.clinicPhone);
|
||||
setClinicAddress(settings.clinicAddress);
|
||||
setTimezone(settings.timezone);
|
||||
setAppointmentDuration(settings.appointmentDuration);
|
||||
setBufferTime(settings.bufferTime);
|
||||
setMaxAdvanceBooking(settings.maxAdvanceBooking);
|
||||
setCancellationDeadline(settings.cancellationDeadline);
|
||||
setAutoConfirmAppointments(settings.autoConfirmAppointments);
|
||||
setEmailNotifications(settings.emailNotifications);
|
||||
setSmsNotifications(settings.smsNotifications);
|
||||
setAppointmentReminders(settings.appointmentReminders);
|
||||
setReminderHoursBefore(settings.reminderHoursBefore);
|
||||
setNewBookingNotifications(settings.newBookingNotifications);
|
||||
setCancellationNotifications(settings.cancellationNotifications);
|
||||
setRequirePaymentUpfront(settings.requirePaymentUpfront);
|
||||
setAllowPartialPayment(settings.allowPartialPayment);
|
||||
setDepositPercentage(settings.depositPercentage);
|
||||
setAcceptCash(settings.acceptCash);
|
||||
setAcceptCard(settings.acceptCard);
|
||||
setAcceptEWallet(settings.acceptEWallet);
|
||||
setTwoFactorAuth(settings.twoFactorAuth);
|
||||
setSessionTimeout(settings.sessionTimeout);
|
||||
setPasswordExpiry(settings.passwordExpiry);
|
||||
setLoginAttempts(settings.loginAttempts);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin settings:", error);
|
||||
toast.error("Failed to load settings");
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveGeneral = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
clinicName,
|
||||
clinicEmail,
|
||||
clinicPhone,
|
||||
clinicAddress,
|
||||
timezone,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save general settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppointments = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
appointmentDuration,
|
||||
bufferTime,
|
||||
maxAdvanceBooking,
|
||||
cancellationDeadline,
|
||||
autoConfirmAppointments,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save appointment settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
emailNotifications,
|
||||
smsNotifications,
|
||||
appointmentReminders,
|
||||
reminderHoursBefore,
|
||||
newBookingNotifications,
|
||||
cancellationNotifications,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save notification settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePayments = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
requirePaymentUpfront,
|
||||
allowPartialPayment,
|
||||
depositPercentage,
|
||||
acceptCash,
|
||||
acceptCard,
|
||||
acceptEWallet,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save payment settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSecurity = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateAdminSettings({
|
||||
twoFactorAuth,
|
||||
sessionTimeout,
|
||||
passwordExpiry,
|
||||
loginAttempts,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save security settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your clinic settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<IconBriefcase className="size-4" />
|
||||
<span className="hidden sm:inline">General</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appointments" className="gap-2">
|
||||
<IconUser className="size-4" />
|
||||
<span className="hidden sm:inline">Appointments</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<IconBell className="size-4" />
|
||||
<span className="hidden sm:inline">Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="payments" className="gap-2">
|
||||
<IconKey className="size-4" />
|
||||
<span className="hidden sm:inline">Payments</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="gap-2">
|
||||
<IconShield className="size-4" />
|
||||
<span className="hidden sm:inline">Security</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General Settings */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Clinic Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your clinic's basic information and contact details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-name">Clinic Name</Label>
|
||||
<Input
|
||||
id="clinic-name"
|
||||
value={clinicName}
|
||||
onChange={(e) => setClinicName(e.target.value)}
|
||||
placeholder="Dental U-Care"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-email">Email Address</Label>
|
||||
<Input
|
||||
id="clinic-email"
|
||||
type="email"
|
||||
value={clinicEmail}
|
||||
onChange={(e) => setClinicEmail(e.target.value)}
|
||||
placeholder="info@dentalucare.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-phone">Phone Number</Label>
|
||||
<Input
|
||||
id="clinic-phone"
|
||||
value={clinicPhone}
|
||||
onChange={(e) => setClinicPhone(e.target.value)}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Select value={timezone} onValueChange={setTimezone}>
|
||||
<SelectTrigger id="timezone">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="America/New_York">
|
||||
Eastern Time (ET)
|
||||
</SelectItem>
|
||||
<SelectItem value="America/Chicago">
|
||||
Central Time (CT)
|
||||
</SelectItem>
|
||||
<SelectItem value="America/Denver">
|
||||
Mountain Time (MT)
|
||||
</SelectItem>
|
||||
<SelectItem value="America/Los_Angeles">
|
||||
Pacific Time (PT)
|
||||
</SelectItem>
|
||||
<SelectItem value="Asia/Manila">
|
||||
Philippine Time (PHT)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clinic-address">Clinic Address</Label>
|
||||
<Textarea
|
||||
id="clinic-address"
|
||||
value={clinicAddress}
|
||||
onChange={(e) => setClinicAddress(e.target.value)}
|
||||
placeholder="123 Medical Plaza, Suite 100"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveGeneral} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Appointment Settings */}
|
||||
<TabsContent value="appointments" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appointment Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Manage appointment durations, booking limits, and scheduling
|
||||
rules
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="appointment-duration">
|
||||
Default Duration (minutes)
|
||||
</Label>
|
||||
<Select
|
||||
value={appointmentDuration}
|
||||
onValueChange={setAppointmentDuration}
|
||||
>
|
||||
<SelectTrigger id="appointment-duration">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="45">45 minutes</SelectItem>
|
||||
<SelectItem value="60">60 minutes</SelectItem>
|
||||
<SelectItem value="90">90 minutes</SelectItem>
|
||||
<SelectItem value="120">120 minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="buffer-time">Buffer Time (minutes)</Label>
|
||||
<Select value={bufferTime} onValueChange={setBufferTime}>
|
||||
<SelectTrigger id="buffer-time">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">No buffer</SelectItem>
|
||||
<SelectItem value="10">10 minutes</SelectItem>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="20">20 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-advance">
|
||||
Maximum Advance Booking (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="max-advance"
|
||||
type="number"
|
||||
value={maxAdvanceBooking}
|
||||
onChange={(e) => setMaxAdvanceBooking(e.target.value)}
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cancellation-deadline">
|
||||
Cancellation Deadline (hours)
|
||||
</Label>
|
||||
<Input
|
||||
id="cancellation-deadline"
|
||||
type="number"
|
||||
value={cancellationDeadline}
|
||||
onChange={(e) => setCancellationDeadline(e.target.value)}
|
||||
min="1"
|
||||
max="72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-confirm Appointments</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically confirm new bookings without manual review
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoConfirmAppointments}
|
||||
onCheckedChange={setAutoConfirmAppointments}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveAppointments} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Configure email, SMS, and reminder notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Email Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via email
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onCheckedChange={setEmailNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>SMS Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via SMS
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={smsNotifications}
|
||||
onCheckedChange={setSmsNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Appointment Reminders</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send reminders to patients before appointments
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appointmentReminders}
|
||||
onCheckedChange={setAppointmentReminders}
|
||||
/>
|
||||
</div>
|
||||
{appointmentReminders && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="reminder-hours">
|
||||
Send reminder (hours before)
|
||||
</Label>
|
||||
<Select
|
||||
value={reminderHoursBefore}
|
||||
onValueChange={setReminderHoursBefore}
|
||||
>
|
||||
<SelectTrigger id="reminder-hours">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2 hours</SelectItem>
|
||||
<SelectItem value="4">4 hours</SelectItem>
|
||||
<SelectItem value="12">12 hours</SelectItem>
|
||||
<SelectItem value="24">24 hours</SelectItem>
|
||||
<SelectItem value="48">48 hours</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>New Booking Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when new appointments are booked
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={newBookingNotifications}
|
||||
onCheckedChange={setNewBookingNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Cancellation Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get notified when appointments are cancelled
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={cancellationNotifications}
|
||||
onCheckedChange={setCancellationNotifications}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveNotifications} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Payment Settings */}
|
||||
<TabsContent value="payments" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Payment Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Manage payment methods and billing preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Require Payment Upfront</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Require full payment when booking
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={requirePaymentUpfront}
|
||||
onCheckedChange={setRequirePaymentUpfront}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Partial Payment</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow patients to pay a deposit
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowPartialPayment}
|
||||
onCheckedChange={setAllowPartialPayment}
|
||||
/>
|
||||
</div>
|
||||
{allowPartialPayment && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="deposit-percentage">
|
||||
Deposit Percentage
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="deposit-percentage"
|
||||
type="number"
|
||||
value={depositPercentage}
|
||||
onChange={(e) => setDepositPercentage(e.target.value)}
|
||||
min="10"
|
||||
max="100"
|
||||
className="pr-8"
|
||||
/>
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<Label>Accepted Payment Methods</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal">Cash</Label>
|
||||
<Switch
|
||||
checked={acceptCash}
|
||||
onCheckedChange={setAcceptCash}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal">Credit/Debit Card</Label>
|
||||
<Switch
|
||||
checked={acceptCard}
|
||||
onCheckedChange={setAcceptCard}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="font-normal">E-Wallet</Label>
|
||||
<Switch
|
||||
checked={acceptEWallet}
|
||||
onCheckedChange={setAcceptEWallet}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSavePayments} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security Settings */}
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security & Privacy</CardTitle>
|
||||
<CardDescription>
|
||||
Manage security settings and access controls
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Two-Factor Authentication</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Require 2FA for all admin accounts
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorAuth}
|
||||
onCheckedChange={setTwoFactorAuth}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-timeout">
|
||||
Session Timeout (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="session-timeout"
|
||||
type="number"
|
||||
value={sessionTimeout}
|
||||
onChange={(e) => setSessionTimeout(e.target.value)}
|
||||
min="15"
|
||||
max="480"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-expiry">
|
||||
Password Expiry (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="password-expiry"
|
||||
type="number"
|
||||
value={passwordExpiry}
|
||||
onChange={(e) => setPasswordExpiry(e.target.value)}
|
||||
min="30"
|
||||
max="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-attempts">
|
||||
Maximum Login Attempts
|
||||
</Label>
|
||||
<Input
|
||||
id="login-attempts"
|
||||
type="number"
|
||||
value={loginAttempts}
|
||||
onChange={(e) => setLoginAttempts(e.target.value)}
|
||||
min="3"
|
||||
max="10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveSecurity} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1090
components/admin/users-table.tsx
Normal file
1090
components/admin/users-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
81
components/calendar/calendar-12.tsx
Normal file
81
components/calendar/calendar-12.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DateRange } from "react-day-picker"
|
||||
import { enUS, es } from "react-day-picker/locale"
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
const localizedStrings = {
|
||||
en: {
|
||||
title: "Book an appointment",
|
||||
description: "Select the dates for your appointment",
|
||||
},
|
||||
es: {
|
||||
title: "Reserva una cita",
|
||||
description: "Selecciona las fechas para tu cita",
|
||||
},
|
||||
} as const
|
||||
|
||||
export default function Calendar12() {
|
||||
const [locale, setLocale] =
|
||||
React.useState<keyof typeof localizedStrings>("es")
|
||||
const [dateRange, setDateRange] = React.useState<DateRange | undefined>({
|
||||
from: new Date(2025, 8, 9),
|
||||
to: new Date(2025, 8, 17),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>{localizedStrings[locale].title}</CardTitle>
|
||||
<CardDescription>
|
||||
{localizedStrings[locale].description}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) =>
|
||||
setLocale(value as keyof typeof localizedStrings)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="es">Español</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
defaultMonth={dateRange?.from}
|
||||
numberOfMonths={2}
|
||||
locale={locale === "es" ? es : enUS}
|
||||
className="bg-transparent p-0"
|
||||
buttonVariant="outline"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
32
components/calendar/calendar-14.tsx
Normal file
32
components/calendar/calendar-14.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
|
||||
export default function Calendar14() {
|
||||
const [date, setDate] = React.useState<Date | undefined>(
|
||||
new Date(2025, 5, 12)
|
||||
)
|
||||
const bookedDates = Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => new Date(2025, 5, 15 + i)
|
||||
)
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
defaultMonth={date}
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
disabled={bookedDates}
|
||||
modifiers={{
|
||||
booked: bookedDates,
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
booked: "[&>button]:line-through opacity-100",
|
||||
}}
|
||||
className="rounded-lg border shadow-sm"
|
||||
/>
|
||||
)
|
||||
}
|
||||
297
components/chart/chart-area-interactive.tsx
Normal file
297
components/chart/chart-area-interactive.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
|
||||
export const description = "An interactive area chart";
|
||||
|
||||
type ChartDataPoint = {
|
||||
date: string;
|
||||
appointments: number;
|
||||
};
|
||||
|
||||
const defaultChartData: ChartDataPoint[] = [
|
||||
{ date: "2024-04-01", appointments: 12 },
|
||||
{ date: "2024-04-02", appointments: 8 },
|
||||
{ date: "2024-04-03", appointments: 15 },
|
||||
{ date: "2024-04-04", appointments: 22 },
|
||||
{ date: "2024-04-05", appointments: 18 },
|
||||
{ date: "2024-04-06", appointments: 25 },
|
||||
{ date: "2024-04-07", appointments: 10 },
|
||||
{ date: "2024-04-08", appointments: 16 },
|
||||
{ date: "2024-04-09", appointments: 9 },
|
||||
{ date: "2024-04-10", appointments: 19 },
|
||||
{ date: "2024-04-11", appointments: 27 },
|
||||
{ date: "2024-04-12", appointments: 21 },
|
||||
{ date: "2024-04-13", appointments: 14 },
|
||||
{ date: "2024-04-14", appointments: 11 },
|
||||
{ date: "2024-04-15", appointments: 17 },
|
||||
{ date: "2024-04-16", appointments: 13 },
|
||||
{ date: "2024-04-17", appointments: 24 },
|
||||
{ date: "2024-04-18", appointments: 28 },
|
||||
{ date: "2024-04-19", appointments: 20 },
|
||||
{ date: "2024-04-20", appointments: 12 },
|
||||
{ date: "2024-04-21", appointments: 16 },
|
||||
{ date: "2024-04-22", appointments: 19 },
|
||||
{ date: "2024-04-23", appointments: 23 },
|
||||
{ date: "2024-04-24", appointments: 26 },
|
||||
{ date: "2024-04-25", appointments: 15 },
|
||||
{ date: "2024-04-26", appointments: 8 },
|
||||
{ date: "2024-04-27", appointments: 29 },
|
||||
{ date: "2024-04-28", appointments: 18 },
|
||||
{ date: "2024-04-29", appointments: 22 },
|
||||
{ date: "2024-04-30", appointments: 25 },
|
||||
{ date: "2024-05-01", appointments: 14 },
|
||||
{ date: "2024-05-02", appointments: 20 },
|
||||
{ date: "2024-05-03", appointments: 17 },
|
||||
{ date: "2024-05-04", appointments: 24 },
|
||||
{ date: "2024-05-05", appointments: 28 },
|
||||
{ date: "2024-05-06", appointments: 30 },
|
||||
{ date: "2024-05-07", appointments: 21 },
|
||||
{ date: "2024-05-08", appointments: 16 },
|
||||
{ date: "2024-05-09", appointments: 19 },
|
||||
{ date: "2024-05-10", appointments: 23 },
|
||||
{ date: "2024-05-11", appointments: 26 },
|
||||
{ date: "2024-05-12", appointments: 18 },
|
||||
{ date: "2024-05-13", appointments: 13 },
|
||||
{ date: "2024-05-14", appointments: 27 },
|
||||
{ date: "2024-05-15", appointments: 25 },
|
||||
{ date: "2024-05-16", appointments: 22 },
|
||||
{ date: "2024-05-17", appointments: 29 },
|
||||
{ date: "2024-05-18", appointments: 24 },
|
||||
{ date: "2024-05-19", appointments: 17 },
|
||||
{ date: "2024-05-20", appointments: 20 },
|
||||
{ date: "2024-05-21", appointments: 11 },
|
||||
{ date: "2024-05-22", appointments: 10 },
|
||||
{ date: "2024-05-23", appointments: 21 },
|
||||
{ date: "2024-05-24", appointments: 19 },
|
||||
{ date: "2024-05-25", appointments: 16 },
|
||||
{ date: "2024-05-26", appointments: 14 },
|
||||
{ date: "2024-05-27", appointments: 28 },
|
||||
{ date: "2024-05-28", appointments: 18 },
|
||||
{ date: "2024-05-29", appointments: 12 },
|
||||
{ date: "2024-05-30", appointments: 23 },
|
||||
{ date: "2024-05-31", appointments: 17 },
|
||||
{ date: "2024-06-01", appointments: 15 },
|
||||
{ date: "2024-06-02", appointments: 26 },
|
||||
{ date: "2024-06-03", appointments: 13 },
|
||||
{ date: "2024-06-04", appointments: 25 },
|
||||
{ date: "2024-06-05", appointments: 11 },
|
||||
{ date: "2024-06-06", appointments: 19 },
|
||||
{ date: "2024-06-07", appointments: 22 },
|
||||
{ date: "2024-06-08", appointments: 24 },
|
||||
{ date: "2024-06-09", appointments: 27 },
|
||||
{ date: "2024-06-10", appointments: 16 },
|
||||
{ date: "2024-06-11", appointments: 10 },
|
||||
{ date: "2024-06-12", appointments: 28 },
|
||||
{ date: "2024-06-13", appointments: 12 },
|
||||
{ date: "2024-06-14", appointments: 25 },
|
||||
{ date: "2024-06-15", appointments: 21 },
|
||||
{ date: "2024-06-16", appointments: 23 },
|
||||
{ date: "2024-06-17", appointments: 29 },
|
||||
{ date: "2024-06-18", appointments: 14 },
|
||||
{ date: "2024-06-19", appointments: 20 },
|
||||
{ date: "2024-06-20", appointments: 26 },
|
||||
{ date: "2024-06-21", appointments: 17 },
|
||||
{ date: "2024-06-22", appointments: 22 },
|
||||
{ date: "2024-06-23", appointments: 30 },
|
||||
{ date: "2024-06-24", appointments: 15 },
|
||||
{ date: "2024-06-25", appointments: 16 },
|
||||
{ date: "2024-06-26", appointments: 24 },
|
||||
{ date: "2024-06-27", appointments: 27 },
|
||||
{ date: "2024-06-28", appointments: 18 },
|
||||
{ date: "2024-06-29", appointments: 13 },
|
||||
{ date: "2024-06-30", appointments: 25 },
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
appointments: {
|
||||
label: "Appointments",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function ChartAreaInteractive({ data }: { data?: ChartDataPoint[] }) {
|
||||
const isMobile = useIsMobile();
|
||||
const [timeRange, setTimeRange] = React.useState("90d");
|
||||
|
||||
// Use provided data or fallback to default
|
||||
const chartData = React.useMemo(() => {
|
||||
if (!data || data.length === 0) {
|
||||
return defaultChartData;
|
||||
}
|
||||
|
||||
// Fill in missing dates with 0 appointments
|
||||
const referenceDate = new Date();
|
||||
const filledData: ChartDataPoint[] = [];
|
||||
|
||||
// Create a map of existing data
|
||||
const dataMap = new Map(data.map((item) => [item.date, item.appointments]));
|
||||
|
||||
// Generate data for last 90 days
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const date = new Date(referenceDate);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
|
||||
filledData.push({
|
||||
date: dateStr,
|
||||
appointments: dataMap.get(dateStr) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
return filledData;
|
||||
}, [data]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d");
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const filteredData = React.useMemo(() => {
|
||||
const referenceDate = new Date();
|
||||
let daysToSubtract = 90;
|
||||
if (timeRange === "30d") {
|
||||
daysToSubtract = 30;
|
||||
} else if (timeRange === "7d") {
|
||||
daysToSubtract = 7;
|
||||
}
|
||||
const startDate = new Date(referenceDate);
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract);
|
||||
|
||||
return chartData.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
return date >= startDate;
|
||||
});
|
||||
}, [chartData, timeRange]);
|
||||
|
||||
return (
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardTitle>Appointment Trends</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="hidden @[540px]/card:block">
|
||||
Appointments over time
|
||||
</span>
|
||||
<span className="@[540px]/card:hidden">Appointments</span>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={timeRange}
|
||||
onValueChange={setTimeRange}
|
||||
variant="outline"
|
||||
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
|
||||
>
|
||||
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
|
||||
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
|
||||
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger
|
||||
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
|
||||
size="sm"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl">
|
||||
<SelectItem value="90d" className="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" className="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" className="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[250px] w-full"
|
||||
>
|
||||
<AreaChart data={filteredData}>
|
||||
<defs>
|
||||
<linearGradient id="fillAppointments" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-appointments)"
|
||||
stopOpacity={1.0}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-appointments)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="appointments"
|
||||
type="natural"
|
||||
fill="url(#fillAppointments)"
|
||||
stroke="var(--color-appointments)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
807
components/chart/data-table.tsx
Normal file
807
components/chart/data-table.tsx
Normal file
@@ -0,0 +1,807 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconCircleCheckFilled,
|
||||
IconDotsVertical,
|
||||
IconGripVertical,
|
||||
IconLayoutColumns,
|
||||
IconLoader,
|
||||
IconPlus,
|
||||
IconTrendingUp,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
Row,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
|
||||
export const schema = z.object({
|
||||
id: z.number(),
|
||||
header: z.string(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
target: z.string(),
|
||||
limit: z.string(),
|
||||
reviewer: z.string(),
|
||||
})
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
function DragHandle({ id }: { id: number }) {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent"
|
||||
>
|
||||
<IconGripVertical className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<z.infer<typeof schema>>[] = [
|
||||
{
|
||||
id: "drag",
|
||||
header: () => null,
|
||||
cell: ({ row }) => <DragHandle id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "header",
|
||||
header: "Header",
|
||||
cell: ({ row }) => {
|
||||
return <TableCellViewer item={row.original} />
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Section Type",
|
||||
cell: ({ row }) => (
|
||||
<div className="w-32">
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.type}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-muted-foreground px-1.5">
|
||||
{row.original.status === "Done" ? (
|
||||
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
|
||||
) : (
|
||||
<IconLoader />
|
||||
)}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "target",
|
||||
header: () => <div className="w-full text-right">Target</div>,
|
||||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
|
||||
Target
|
||||
</Label>
|
||||
<Input
|
||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
||||
defaultValue={row.original.target}
|
||||
id={`${row.original.id}-target`}
|
||||
/>
|
||||
</form>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "limit",
|
||||
header: () => <div className="w-full text-right">Limit</div>,
|
||||
cell: ({ row }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
|
||||
loading: `Saving ${row.original.header}`,
|
||||
success: "Done",
|
||||
error: "Error",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
|
||||
Limit
|
||||
</Label>
|
||||
<Input
|
||||
className="hover:bg-input/30 focus-visible:bg-background dark:hover:bg-input/30 dark:focus-visible:bg-input/30 h-8 w-16 border-transparent bg-transparent text-right shadow-none focus-visible:border dark:bg-transparent"
|
||||
defaultValue={row.original.limit}
|
||||
id={`${row.original.id}-limit`}
|
||||
/>
|
||||
</form>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reviewer",
|
||||
header: "Reviewer",
|
||||
cell: ({ row }) => {
|
||||
const isAssigned = row.original.reviewer !== "Assign reviewer"
|
||||
|
||||
if (isAssigned) {
|
||||
return row.original.reviewer
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
|
||||
Reviewer
|
||||
</Label>
|
||||
<Select>
|
||||
<SelectTrigger
|
||||
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
|
||||
size="sm"
|
||||
id={`${row.original.id}-reviewer`}
|
||||
>
|
||||
<SelectValue placeholder="Assign reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
||||
<DropdownMenuItem>Favorite</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
|
||||
const { transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: row.original.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
data-dragging={isDragging}
|
||||
ref={setNodeRef}
|
||||
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data: initialData,
|
||||
}: {
|
||||
data: z.infer<typeof schema>[]
|
||||
}) {
|
||||
const [data, setData] = React.useState(() => initialData)
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
[]
|
||||
)
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const sortableId = React.useId()
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
||||
() => data?.map(({ id }) => id) || [],
|
||||
[data]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
getRowId: (row) => row.id.toString(),
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id)
|
||||
const newIndex = dataIds.indexOf(over.id)
|
||||
return arrayMove(data, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue="outline"
|
||||
className="w-full flex-col justify-start gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||
<Label htmlFor="view-selector" className="sr-only">
|
||||
View
|
||||
</Label>
|
||||
<Select defaultValue="outline">
|
||||
<SelectTrigger
|
||||
className="flex w-fit @4xl/main:hidden"
|
||||
size="sm"
|
||||
id="view-selector"
|
||||
>
|
||||
<SelectValue placeholder="Select a view" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="past-performance">Past Performance</SelectItem>
|
||||
<SelectItem value="key-personnel">Key Personnel</SelectItem>
|
||||
<SelectItem value="focus-documents">Focus Documents</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TabsList className="**:data-[slot=badge]:bg-muted-foreground/30 hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:px-1 @4xl/main:flex">
|
||||
<TabsTrigger value="outline">Outline</TabsTrigger>
|
||||
<TabsTrigger value="past-performance">
|
||||
Past Performance <Badge variant="secondary">3</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key-personnel">
|
||||
Key Personnel <Badge variant="secondary">2</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Customize Columns</span>
|
||||
<span className="lg:hidden">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" &&
|
||||
column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconPlus />
|
||||
<span className="hidden lg:inline">Add Section</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="outline"
|
||||
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
|
||||
>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}
|
||||
id={sortableId}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className="bg-muted sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="**:data-[slot=table-cell]:first:w-8">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<DraggableRow key={row.id} row={row} />
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="past-performance"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="focus-documents"
|
||||
className="flex flex-col px-4 lg:px-6"
|
||||
>
|
||||
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
{ month: "January", desktop: 186, mobile: 80 },
|
||||
{ month: "February", desktop: 305, mobile: 200 },
|
||||
{ month: "March", desktop: 237, mobile: 120 },
|
||||
{ month: "April", desktop: 73, mobile: 190 },
|
||||
{ month: "May", desktop: 209, mobile: 130 },
|
||||
{ month: "June", desktop: 214, mobile: 140 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
function TableCellViewer({ item }: { item: z.infer<typeof schema> }) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<Drawer direction={isMobile ? "bottom" : "right"}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="link" className="text-foreground w-fit px-0 text-left">
|
||||
{item.header}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="gap-1">
|
||||
<DrawerTitle>{item.header}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Showing total visitors for the last 6 months
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
|
||||
{!isMobile && (
|
||||
<>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 0,
|
||||
right: 10,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
hide
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="mobile"
|
||||
type="natural"
|
||||
fill="var(--color-mobile)"
|
||||
fillOpacity={0.6}
|
||||
stroke="var(--color-mobile)"
|
||||
stackId="a"
|
||||
/>
|
||||
<Area
|
||||
dataKey="desktop"
|
||||
type="natural"
|
||||
fill="var(--color-desktop)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-desktop)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
<Separator />
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2 leading-none font-medium">
|
||||
Trending up by 5.2% this month{" "}
|
||||
<IconTrendingUp className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Showing total visitors for the last 6 months. This is just
|
||||
some random text to test the layout. It spans multiple lines
|
||||
and should wrap around.
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<form className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="header">Header</Label>
|
||||
<Input id="header" defaultValue={item.header} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select defaultValue={item.type}>
|
||||
<SelectTrigger id="type" className="w-full">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Table of Contents">
|
||||
Table of Contents
|
||||
</SelectItem>
|
||||
<SelectItem value="Executive Summary">
|
||||
Executive Summary
|
||||
</SelectItem>
|
||||
<SelectItem value="Technical Approach">
|
||||
Technical Approach
|
||||
</SelectItem>
|
||||
<SelectItem value="Design">Design</SelectItem>
|
||||
<SelectItem value="Capabilities">Capabilities</SelectItem>
|
||||
<SelectItem value="Focus Documents">
|
||||
Focus Documents
|
||||
</SelectItem>
|
||||
<SelectItem value="Narrative">Narrative</SelectItem>
|
||||
<SelectItem value="Cover Page">Cover Page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select defaultValue={item.status}>
|
||||
<SelectTrigger id="status" className="w-full">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Done">Done</SelectItem>
|
||||
<SelectItem value="In Progress">In Progress</SelectItem>
|
||||
<SelectItem value="Not Started">Not Started</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="target">Target</Label>
|
||||
<Input id="target" defaultValue={item.target} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="limit">Limit</Label>
|
||||
<Input id="limit" defaultValue={item.limit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="reviewer">Reviewer</Label>
|
||||
<Select defaultValue={item.reviewer}>
|
||||
<SelectTrigger id="reviewer" className="w-full">
|
||||
<SelectValue placeholder="Select a reviewer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
|
||||
<SelectItem value="Jamik Tashpulatov">
|
||||
Jamik Tashpulatov
|
||||
</SelectItem>
|
||||
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Done</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
316
components/dentist/appointments-list.tsx
Normal file
316
components/dentist/appointments-list.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Calendar, Clock, Phone, Mail } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Appointment = {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
patient: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
medicalHistory: string | null;
|
||||
};
|
||||
service: {
|
||||
name: string;
|
||||
duration: number;
|
||||
price: number;
|
||||
};
|
||||
payment: {
|
||||
status: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type DentistAppointmentsListProps = {
|
||||
appointments: Appointment[];
|
||||
};
|
||||
|
||||
export function DentistAppointmentsList({
|
||||
appointments,
|
||||
}: DentistAppointmentsListProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const pendingAppointments = appointments.filter(
|
||||
(apt) => apt.status === "pending"
|
||||
);
|
||||
const upcomingAppointments = appointments.filter(
|
||||
(apt) => new Date(apt.date) >= new Date() && apt.status === "confirmed"
|
||||
);
|
||||
const completedAppointments = appointments.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
);
|
||||
|
||||
const handleConfirmAppointment = async (appointmentId: string) => {
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "confirmed",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to confirm appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment confirmed successfully");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to confirm appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeclineAppointment = async (appointmentId: string) => {
|
||||
if (!confirm("Are you sure you want to decline this appointment?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "cancelled",
|
||||
cancelReason: "Declined by dentist",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to decline appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment declined");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to decline appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompleteAppointment = async (appointmentId: string) => {
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "completed",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to complete appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment marked as completed");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to complete appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAppointmentCard = (
|
||||
appointment: Appointment,
|
||||
showActions: boolean = true
|
||||
) => (
|
||||
<Card key={appointment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{appointment.patient.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{appointment.service.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(appointment.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{new Date(appointment.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{appointment.timeSlot}</span>
|
||||
</div>
|
||||
{appointment.patient.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{appointment.patient.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{appointment.patient.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appointment.patient.medicalHistory && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Medical History:</p>
|
||||
<p className="text-muted-foreground">
|
||||
{appointment.patient.medicalHistory}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.notes && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Patient Notes:</p>
|
||||
<p className="text-muted-foreground">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
<div className="flex gap-2">
|
||||
{appointment.status === "pending" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleConfirmAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
{isLoading === appointment.id ? "Confirming..." : "Confirm"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleDeclineAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{appointment.status === "confirmed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleCompleteAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
{isLoading === appointment.id
|
||||
? "Completing..."
|
||||
: "Mark as Completed"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="pending" className="w-full">
|
||||
<TabsList className="grid w-full max-w-2xl grid-cols-3">
|
||||
<TabsTrigger value="pending">
|
||||
Pending ({pendingAppointments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="completed">
|
||||
Completed ({completedAppointments.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending" className="space-y-4 mt-6">
|
||||
{pendingAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No pending appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
pendingAppointments.map((apt) => renderAppointmentCard(apt))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upcoming" className="space-y-4 mt-6">
|
||||
{upcomingAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No upcoming appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
upcomingAppointments.map((apt) => renderAppointmentCard(apt))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="completed" className="space-y-4 mt-6">
|
||||
{completedAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No completed appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
completedAppointments.map((apt) => renderAppointmentCard(apt, false))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
139
components/dentist/patients-table.tsx
Normal file
139
components/dentist/patients-table.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Search, Mail, Phone } from "lucide-react"
|
||||
|
||||
type Patient = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
medicalHistory: string | null
|
||||
appointments: Array<{
|
||||
id: string
|
||||
date: Date
|
||||
status: string
|
||||
service: {
|
||||
name: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
type DentistPatientsTableProps = {
|
||||
patients: Patient[]
|
||||
}
|
||||
|
||||
export function DentistPatientsTable({ patients }: DentistPatientsTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredPatients = patients.filter((patient) => {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
patient.name.toLowerCase().includes(query) ||
|
||||
patient.email.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>My Patients</CardTitle>
|
||||
<CardDescription>
|
||||
Total: {patients.length} patients
|
||||
</CardDescription>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Patient Name</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>Total Visits</TableHead>
|
||||
<TableHead>Last Visit</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPatients.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||
No patients found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredPatients.map((patient) => {
|
||||
const completedVisits = patient.appointments.filter(
|
||||
(apt) => apt.status === "completed"
|
||||
).length
|
||||
const lastVisit = patient.appointments[0]
|
||||
|
||||
return (
|
||||
<TableRow key={patient.id}>
|
||||
<TableCell className="font-medium">{patient.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="text-xs">{patient.email}</span>
|
||||
</div>
|
||||
{patient.phone && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Phone className="h-3 w-3" />
|
||||
<span className="text-xs">{patient.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{patient.appointments.length} total</p>
|
||||
<p className="text-xs text-muted-foreground">{completedVisits} completed</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{lastVisit ? (
|
||||
<div>
|
||||
<p>{new Date(lastVisit.date).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{lastVisit.service.name}</p>
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="outline" size="sm">
|
||||
View History
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
358
components/emails/email-bookings.tsx
Normal file
358
components/emails/email-bookings.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface ServiceItem {
|
||||
description: string;
|
||||
qty: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DentalInvoiceProps {
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
dueDate: string;
|
||||
patientName: string;
|
||||
patientAddress: string;
|
||||
patientCity: string;
|
||||
patientPhone: string;
|
||||
patientEmail: string;
|
||||
bookingId: string;
|
||||
appointmentDate: string;
|
||||
appointmentTime: string;
|
||||
doctorName: string;
|
||||
treatmentRoom: string;
|
||||
appointmentDuration: string;
|
||||
reasonForVisit: string;
|
||||
pdfDownloadUrl: string;
|
||||
paymentStatus: string;
|
||||
nextAppointmentDate: string;
|
||||
nextAppointmentTime: string;
|
||||
nextAppointmentPurpose: string;
|
||||
services: ServiceItem[];
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
totalDue: number;
|
||||
}
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Row,
|
||||
Column,
|
||||
Text,
|
||||
Heading,
|
||||
Hr,
|
||||
Button,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
|
||||
const DentalInvoice: React.FC<DentalInvoiceProps> = (props) => {
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white max-w-[600px] mx-auto rounded-[8px] shadow-lg">
|
||||
{/* Header */}
|
||||
<Section className="bg-blue-600 text-white p-[32px] rounded-t-[8px]">
|
||||
<Heading className="text-[28px] font-bold m-0 text-center">
|
||||
DENTAL U CARE
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-center m-0 mt-[8px] opacity-90">
|
||||
Professional Dental Services
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Invoice Header */}
|
||||
<Section className="p-[32px]">
|
||||
<Row>
|
||||
<Column>
|
||||
<Heading className="text-[24px] font-bold text-gray-800 m-0">
|
||||
INVOICE
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-600 m-0 mt-[4px]">
|
||||
Invoice #: {props.invoiceNumber}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Date: {props.invoiceDate}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Due Date: {props.dueDate}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
<Text className="text-[14px] text-gray-600 m-0 font-semibold">
|
||||
Dental U Care Clinic
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Baltan Street
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Puerto Princesa City, Palawan 5300
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Phone: (043) 756-1234
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 mx-[32px]" />
|
||||
|
||||
{/* Patient Information */}
|
||||
<Section className="px-[32px] py-[24px]">
|
||||
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
|
||||
Bill To:
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 m-0 font-semibold">
|
||||
{props.patientName}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
{props.patientAddress}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
{props.patientCity}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Phone: {props.patientPhone}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 m-0">
|
||||
Email: {props.patientEmail}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 mx-[32px]" />
|
||||
|
||||
{/* Booking Details */}
|
||||
<Section className="px-[32px] py-[24px] bg-blue-50 mx-[32px] rounded-[8px]">
|
||||
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
|
||||
Appointment Details:
|
||||
</Heading>
|
||||
<Row>
|
||||
<Column className="w-[50%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Booking ID:</strong> {props.bookingId}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Appointment Date:</strong> {props.appointmentDate}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Time:</strong> {props.appointmentTime}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[50%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Doctor:</strong> {props.doctorName}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Treatment Room:</strong> {props.treatmentRoom}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Duration:</strong> {props.appointmentDuration}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mt-[12px]">
|
||||
<strong>Reason for Visit:</strong> {props.reasonForVisit}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 mx-[32px] my-[24px]" />
|
||||
|
||||
{/* Services Table */}
|
||||
<Section className="px-[32px] py-[24px]">
|
||||
<Heading className="text-[18px] font-semibold text-gray-800 m-0 mb-[16px]">
|
||||
Services Rendered:
|
||||
</Heading>
|
||||
|
||||
{/* Table Header */}
|
||||
<Row className="bg-gray-50 border-solid border-[1px] border-gray-200">
|
||||
<Column className="p-[12px] w-[50%]">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Description
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Qty
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[20%] text-center">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Unit Price
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] font-semibold text-gray-700 m-0">
|
||||
Total
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
{/* Dynamic Service Items */}
|
||||
{props.services.map((service, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
className="border-solid border-[1px] border-t-0 border-gray-200"
|
||||
>
|
||||
<Column className="p-[12px] w-[50%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
{service.description}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
{service.qty}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[20%] text-center">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{service.unitPrice.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="p-[12px] w-[15%] text-center">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{service.total.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Totals */}
|
||||
<Section className="px-[32px]">
|
||||
<Row>
|
||||
<Column className="w-[70%]"></Column>
|
||||
<Column className="w-[30%]">
|
||||
<Row className="mb-[8px]">
|
||||
<Column className="w-[60%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
Subtotal:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[40%] text-right">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{props.subtotal.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[8px]">
|
||||
<Column className="w-[60%]">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
Tax (12%):
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[40%] text-right">
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
₱{props.tax.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Hr className="border-gray-300 my-[8px]" />
|
||||
<Row>
|
||||
<Column className="w-[60%]">
|
||||
<Text className="text-[16px] font-bold text-gray-800 m-0">
|
||||
Total Due:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column className="w-[40%] text-right">
|
||||
<Text className="text-[16px] font-bold text-blue-600 m-0">
|
||||
₱{props.totalDue.toFixed(2)}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Download PDF Button */}
|
||||
<Section className="px-[32px] py-[24px] text-center">
|
||||
<Button
|
||||
href={props.pdfDownloadUrl}
|
||||
className="box-border bg-blue-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline inline-block"
|
||||
>
|
||||
📄 Download PDF Invoice
|
||||
</Button>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mt-[12px]">
|
||||
Click the button above to download a PDF copy of this invoice
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Payment Information */}
|
||||
<Section className="px-[32px] py-[24px] bg-green-50 mx-[32px] my-[24px] rounded-[8px]">
|
||||
<Heading className="text-[16px] font-semibold text-gray-800 m-0 mb-[12px]">
|
||||
Payment Information
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Payment Status:</strong> {props.paymentStatus}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Payment Methods:</strong> Cash, Credit Card, Bank
|
||||
Transfer, GCash, PayMaya
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Bank Details:</strong> BPI - Account #1234567890 (Dental
|
||||
U Care Clinic)
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>GCash:</strong> 09171234567
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
<strong>Payment Terms:</strong> Payment due within 30 days of
|
||||
invoice date
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Next Appointment */}
|
||||
<Section className="px-[32px] py-[24px] bg-yellow-50 mx-[32px] rounded-[8px]">
|
||||
<Heading className="text-[16px] font-semibold text-gray-800 m-0 mb-[12px]">
|
||||
Next Appointment Reminder
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Follow-up Date:</strong> {props.nextAppointmentDate}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0 mb-[8px]">
|
||||
<strong>Time:</strong> {props.nextAppointmentTime}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 m-0">
|
||||
<strong>Purpose:</strong> {props.nextAppointmentPurpose}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Footer */}
|
||||
<Section className="px-[32px] py-[24px] text-center border-t-[1px] border-solid border-gray-200">
|
||||
<Text className="text-[14px] text-gray-600 m-0 mb-[8px]">
|
||||
Thank you for choosing Dental U Care for your oral health needs!
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
For questions about this invoice, please contact us at
|
||||
billing@dentalucare.com or (043) 756-1234
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
To reschedule or cancel appointments, call us at least 24 hours
|
||||
in advance
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Company Footer */}
|
||||
<Section className="px-[32px] py-[16px] bg-gray-50 text-center rounded-b-[8px]">
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
Dental U Care Clinic | Baltan Street, Puerto Princesa City, Palawan
|
||||
5300
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© 2025 Dental U Care. All rights reserved. | License
|
||||
#DC-2025-001
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default DentalInvoice;
|
||||
198
components/emails/email-remainder.tsx
Normal file
198
components/emails/email-remainder.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind,
|
||||
Row,
|
||||
Column,
|
||||
Hr,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface DentalAppointmentReminderProps {
|
||||
patientName: string;
|
||||
appointmentDate: string;
|
||||
appointmentTime: string;
|
||||
doctorName: string;
|
||||
treatmentType: string;
|
||||
duration: string;
|
||||
clinicPhone: string;
|
||||
clinicEmail: string;
|
||||
clinicAddress: string;
|
||||
}
|
||||
|
||||
interface DentalAppointmentReminderComponent
|
||||
extends React.FC<DentalAppointmentReminderProps> {
|
||||
PreviewProps?: DentalAppointmentReminderProps;
|
||||
}
|
||||
|
||||
const DentalAppointmentReminder: DentalAppointmentReminderComponent = (
|
||||
props: DentalAppointmentReminderProps
|
||||
) => {
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Preview>
|
||||
Your upcoming appointment at Dental U Care - {props.appointmentDate}
|
||||
</Preview>
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
|
||||
{/* Header */}
|
||||
<Section className="text-center mb-[32px]">
|
||||
<Heading className="text-[28px] font-bold text-blue-600 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-gray-600 m-0">
|
||||
Your Smile, Our Priority
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section className="mb-[32px]">
|
||||
<Heading className="text-[24px] font-bold text-gray-800 mb-[16px]">
|
||||
Appointment Reminder
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] leading-[24px]">
|
||||
Dear {props.patientName},
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] leading-[24px]">
|
||||
This is a friendly reminder about your upcoming dental
|
||||
appointment at Dental U Care.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Appointment Details */}
|
||||
<Section className="bg-blue-50 rounded-[8px] p-[24px] mb-[32px]">
|
||||
<Heading className="text-[20px] font-bold text-blue-800 mb-[16px]">
|
||||
Appointment Details
|
||||
</Heading>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Date:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.appointmentDate}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Time:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.appointmentTime}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Doctor:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.doctorName}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row className="mb-[12px]">
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Treatment:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.treatmentType}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column className="w-[120px]">
|
||||
<Text className="text-[14px] font-semibold text-gray-600 m-0">
|
||||
Duration:
|
||||
</Text>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text className="text-[14px] text-gray-800 m-0">
|
||||
{props.duration}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Important Notes */}
|
||||
<Section className="mb-[32px]">
|
||||
<Heading className="text-[18px] font-bold text-gray-800 mb-[16px]">
|
||||
Important Reminders
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• Please arrive 10 minutes early for check-in
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• Bring a valid ID and insurance card
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• If you need to reschedule, please call us at least 24 hours in
|
||||
advance
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-700 mb-[8px] leading-[20px]">
|
||||
• Continue your regular oral hygiene routine before your visit
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Section className="mb-[32px]">
|
||||
<Heading className="text-[18px] font-bold text-gray-800 mb-[16px]">
|
||||
Need to Make Changes?
|
||||
</Heading>
|
||||
<Text className="text-[16px] text-gray-700 mb-[16px] leading-[24px]">
|
||||
If you need to reschedule or cancel your appointment, please
|
||||
contact us:
|
||||
</Text>
|
||||
<Text className="text-[16px] text-blue-600 font-semibold mb-[8px]">
|
||||
Phone: {props.clinicPhone}
|
||||
</Text>
|
||||
<Text className="text-[16px] text-blue-600 font-semibold mb-[16px]">
|
||||
Email: {props.clinicEmail}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-300 mb-[32px]" />
|
||||
|
||||
{/* Footer */}
|
||||
<Section className="text-center">
|
||||
<Text className="text-[14px] text-gray-600 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 mb-[8px] m-0">
|
||||
{props.clinicAddress}
|
||||
</Text>
|
||||
<Text className="text-[14px] text-gray-600 mb-[16px]">
|
||||
Phone: {props.clinicPhone} | Email: {props.clinicEmail}
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© {new Date().getFullYear()} Dental U Care. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
export default DentalAppointmentReminder;
|
||||
115
components/emails/email-verification.tsx
Normal file
115
components/emails/email-verification.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
Hr,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface VerificationEmailProps {
|
||||
username ?: string;
|
||||
verificationUrl?: string;
|
||||
}
|
||||
|
||||
const VerificationEmail = (props: VerificationEmailProps) => {
|
||||
const { username, verificationUrl } = props;
|
||||
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
|
||||
{/* Header */}
|
||||
<Section className="text-center mb-[32px]">
|
||||
<Text className="text-[32px] font-bold text-blue-600 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-600 m-0">
|
||||
Your Trusted Dental Care Partner
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section>
|
||||
<Text className="text-[24px] font-bold text-gray-800 mb-[24px] m-0">
|
||||
Verify Your Email Address
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
Thank you {username} for choosing Dental U Care! To complete your account
|
||||
setup and ensure secure access to your dental care portal,
|
||||
please verify your email address by clicking the button below.
|
||||
</Text>
|
||||
|
||||
{/* Verification Button */}
|
||||
<Section className="text-center my-[32px]">
|
||||
<Button
|
||||
href={verificationUrl}
|
||||
className="bg-blue-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline box-border hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Verify Email Address
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] text-gray-600 mb-[24px] m-0 leading-[20px]">
|
||||
If the button above doesn`t work, you can also copy and paste
|
||||
this link into your browser:
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-blue-600 mb-[32px] m-0 break-all">
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
This verification link will expire in 24 hours for your
|
||||
security. If you didn`t create an account with Dental U Care,
|
||||
please ignore this email.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 my-[32px]" />
|
||||
|
||||
{/* Footer */}
|
||||
<Section>
|
||||
<Text className="text-[14px] text-gray-600 mb-[16px] m-0">
|
||||
Need help? Contact our support team at{" "}
|
||||
<a
|
||||
href="mailto:support@dentalucare.com"
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
send@dentalucare.tech
|
||||
</a>{" "}
|
||||
or call us at (+63) 917-123-4567.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Dental U Care Clinic
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Baltan Street, Barangay San Miguel
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[16px]">
|
||||
Puerto Princesa, Palawan, Philippines
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© 2025 Dental U Care. All rights reserved.{" "}
|
||||
<a href="#" className="text-blue-600 no-underline">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default VerificationEmail;
|
||||
134
components/emails/reset-password.tsx
Normal file
134
components/emails/reset-password.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Html,
|
||||
Head,
|
||||
Body,
|
||||
Container,
|
||||
Section,
|
||||
Text,
|
||||
Button,
|
||||
Hr,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
interface ForgotPasswordEmailProps {
|
||||
username?: string;
|
||||
resetUrl?: string;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
const ForgotPasswordEmail = (props: ForgotPasswordEmailProps) => {
|
||||
const { username, resetUrl, userEmail } = props;
|
||||
|
||||
return (
|
||||
<Html lang="en" dir="ltr">
|
||||
<Tailwind>
|
||||
<Head />
|
||||
<Body className="bg-gray-100 font-sans py-[40px]">
|
||||
<Container className="bg-white rounded-[8px] shadow-lg max-w-[600px] mx-auto p-[40px]">
|
||||
{/* Header */}
|
||||
<Section className="text-center mb-[32px]">
|
||||
<Text className="text-[32px] font-bold text-blue-600 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[16px] text-gray-600 m-0">
|
||||
Your Trusted Dental Care Partner
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Main Content */}
|
||||
<Section>
|
||||
<Text className="text-[24px] font-bold text-gray-800 mb-[24px] m-0">
|
||||
Reset Your Password
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
Hello {username}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
We received a request to reset the password for your {userEmail} Dental U
|
||||
Care account. Don`t worry - it happens to the best of us! Click
|
||||
the button below to create a new password.
|
||||
</Text>
|
||||
|
||||
{/* Reset Password Button */}
|
||||
<Section className="text-center my-[32px]">
|
||||
<Button
|
||||
href={resetUrl}
|
||||
className="bg-green-600 text-white px-[32px] py-[16px] rounded-[8px] text-[16px] font-semibold no-underline box-border hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] text-gray-600 mb-[24px] m-0 leading-[20px]">
|
||||
If the button above doesn`t work, you can also copy and paste
|
||||
this link into your browser:
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] text-blue-600 mb-[32px] m-0 break-all">
|
||||
{resetUrl}
|
||||
</Text>
|
||||
|
||||
<Section className="bg-yellow-50 border-l-[4px] border-yellow-400 p-[16px] mb-[24px] rounded-[4px]">
|
||||
<Text className="text-[14px] text-yellow-800 m-0 font-semibold mb-[8px]">
|
||||
Important Security Information:
|
||||
</Text>
|
||||
<Text className="text-[14px] text-yellow-700 m-0 leading-[20px]">
|
||||
• This reset link will expire in 1 hour for your security
|
||||
<br />
|
||||
• If you didn`t request this password reset, please ignore
|
||||
this email
|
||||
<br />• Your current password will remain unchanged until you
|
||||
create a new one
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[16px] text-gray-700 mb-[24px] m-0 leading-[24px]">
|
||||
For your account security, we recommend choosing a strong
|
||||
password that includes a mix of letters, numbers, and special
|
||||
characters.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr className="border-gray-200 my-[32px]" />
|
||||
|
||||
{/* Footer */}
|
||||
<Section>
|
||||
<Text className="text-[14px] text-gray-600 mb-[16px] m-0">
|
||||
Having trouble? Our support team is here to help at{" "}
|
||||
<a
|
||||
href="mailto:info@dentalucare.com"
|
||||
className="text-blue-600 no-underline"
|
||||
>
|
||||
info@dentalucare.com
|
||||
</a>{" "}
|
||||
or call us at (+63) 917-123-4567.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Dental U Care
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[8px]">
|
||||
Baltan Street, Barangay San Miguel
|
||||
</Text>
|
||||
<Text className="text-[12px] text-gray-500 m-0 mb-[16px]">
|
||||
Puerto Princesa, Palawan, Philippines
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] text-gray-500 m-0">
|
||||
© 2025 Dental U Care. All rights reserved.{" "}
|
||||
<a href="#" className="text-blue-600 no-underline">
|
||||
Unsubscribe
|
||||
</a>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ForgotPasswordEmail;
|
||||
169
components/landing/about.tsx
Normal file
169
components/landing/about.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
|
||||
interface About3Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
mainImage?: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
secondaryImage?: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
breakout?: {
|
||||
src: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonUrl?: string;
|
||||
};
|
||||
companiesTitle?: string;
|
||||
companies?: Array<{
|
||||
src: string;
|
||||
alt: string;
|
||||
}>;
|
||||
achievementsTitle?: string;
|
||||
achievementsDescription?: string;
|
||||
achievements?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const defaultCompanies = [
|
||||
{
|
||||
src: "/tooth.svg",
|
||||
alt: "tooth",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultAchievements = [
|
||||
{ label: "Happy Patients", value: "500+" },
|
||||
{ label: "Appointments Booked", value: "1000+" },
|
||||
{ label: "Satisfaction Rate", value: "98%" },
|
||||
{ label: "Expert Dentists", value: "4" },
|
||||
];
|
||||
|
||||
const About = ({
|
||||
title = "About Dental U Care",
|
||||
description = "We're a modern dental care provider committed to making quality dental services accessible through our innovative online appointment system. Experience hassle-free booking and world-class dental care.",
|
||||
mainImage = {
|
||||
src: "/clinic.jpg",
|
||||
alt: "Modern dental clinic interior",
|
||||
},
|
||||
secondaryImage = {
|
||||
src: "/team.jpg",
|
||||
alt: "Professional dental team",
|
||||
},
|
||||
breakout = {
|
||||
src: "/tooth.svg",
|
||||
alt: "Dental U Care Logo",
|
||||
title: "Book Your Appointment in Minutes",
|
||||
description:
|
||||
"Our easy-to-use online booking system lets you schedule appointments 24/7, choose your preferred dentist, and manage your dental health journey.",
|
||||
buttonText: "Book Now",
|
||||
buttonUrl: "patient/book-appointment",
|
||||
},
|
||||
companiesTitle = "Trusted Insurance Partners",
|
||||
companies = defaultCompanies,
|
||||
achievementsTitle = "Our Impact in Numbers",
|
||||
achievementsDescription = "Providing quality dental care and making appointments easier for thousands of patients across the Philippines.",
|
||||
achievements = defaultAchievements,
|
||||
}: About3Props = {}) => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mb-14 grid gap-5 text-center md:grid-cols-2 md:text-left">
|
||||
<h1 className="text-5xl font-semibold">{title}</h1>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="grid gap-7 lg:grid-cols-3">
|
||||
<Image
|
||||
src={mainImage.src}
|
||||
alt={mainImage.alt}
|
||||
className="size-full max-h-[620px] rounded-xl object-cover lg:col-span-2"
|
||||
width={600}
|
||||
height={400}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col gap-7 md:flex-row lg:flex-col">
|
||||
<div className="bg-muted flex flex-col justify-between gap-6 rounded-xl p-7 md:w-1/2 lg:w-auto">
|
||||
<Image
|
||||
src={breakout.src}
|
||||
alt={breakout.alt}
|
||||
className="mr-auto h-12"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-2 text-lg font-semibold">{breakout.title}</p>
|
||||
<p className="text-muted-foreground">{breakout.description}</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mr-auto" asChild>
|
||||
<a href={breakout.buttonUrl} target="_blank">
|
||||
{breakout.buttonText}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<Image
|
||||
src={secondaryImage.src}
|
||||
alt={secondaryImage.alt}
|
||||
className="grow basis-0 rounded-xl object-cover md:w-1/2 lg:min-h-0 lg:w-auto"
|
||||
width={600}
|
||||
height={400}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-32">
|
||||
<p className="text-center">{companiesTitle} </p>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-8">
|
||||
{companies.map((company, idx) => (
|
||||
<div className="flex items-center gap-3" key={company.src + idx}>
|
||||
<Image
|
||||
src={company.src}
|
||||
alt={company.alt}
|
||||
className="h-6 w-auto md:h-8"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/30 dark:via-purple-950/30 dark:to-pink-950/30 p-10 md:p-16 shadow-xl">
|
||||
<div className="flex flex-col gap-4 text-center md:text-left relative z-10">
|
||||
<h2 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">{achievementsTitle}</h2>
|
||||
<p className="text-muted-foreground max-w-xl">
|
||||
{achievementsDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-wrap justify-between gap-10 text-center relative z-10">
|
||||
{achievements.map((item, idx) => {
|
||||
const gradients = [
|
||||
"from-blue-500 to-cyan-500",
|
||||
"from-purple-500 to-pink-500",
|
||||
"from-green-500 to-emerald-500",
|
||||
"from-orange-500 to-red-500",
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-4 group" key={item.label + idx}>
|
||||
<p className="font-semibold text-muted-foreground group-hover:text-foreground transition-colors">{item.label}</p>
|
||||
<span className={`text-4xl font-bold md:text-5xl bg-gradient-to-r ${gradients[idx % gradients.length]} bg-clip-text text-transparent`}>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute -top-1 right-1 z-0 hidden h-full w-full bg-[linear-gradient(to_right,hsl(var(--primary))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--primary))_1px,transparent_1px)] bg-[size:80px_80px] opacity-5 [mask-image:linear-gradient(to_bottom_right,#000,transparent,transparent)] md:block"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { About };
|
||||
89
components/landing/contact.tsx
Normal file
89
components/landing/contact.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface Contact2Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
web?: { label: string; url: string };
|
||||
}
|
||||
|
||||
const Contact = ({
|
||||
title = "Get In Touch",
|
||||
description = "Have questions about our services or need help with booking? We're here to help! Reach out to us and we'll get back to you as soon as possible.",
|
||||
phone = "(+63) 917-123-4567",
|
||||
email = "info@dentalucare.com",
|
||||
web = { label: "dentalucare.com", url: "https://dentalucare.com" },
|
||||
}: Contact2Props) => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-10 lg:flex-row lg:gap-20">
|
||||
<div className="mx-auto flex max-w-sm flex-col justify-between gap-10">
|
||||
<div className="text-center lg:text-left">
|
||||
<h1 className="mb-2 text-5xl font-semibold lg:mb-1 lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="mx-auto w-fit lg:mx-0">
|
||||
<h3 className="mb-6 text-center text-2xl font-semibold lg:text-left">
|
||||
Contact Details
|
||||
</h3>
|
||||
<ul className="ml-4 list-disc">
|
||||
<li>
|
||||
<span className="font-bold">Phone: </span>
|
||||
{phone}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-bold">Email: </span>
|
||||
<a href={`mailto:${email}`} className="underline">
|
||||
{email}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-bold">Web: </span>
|
||||
<a href={web.url} target="_blank" className="underline">
|
||||
{web.label}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 rounded-lg border p-10">
|
||||
<div className="flex gap-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="firstname">First Name</Label>
|
||||
<Input type="text" id="firstname" placeholder="First Name" />
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="lastname">Last Name</Label>
|
||||
<Input type="text" id="lastname" placeholder="Last Name" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="email" placeholder="Email" />
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="subject">Subject</Label>
|
||||
<Input type="text" id="subject" placeholder="Subject" />
|
||||
</div>
|
||||
<div className="grid w-full gap-1.5">
|
||||
<Label htmlFor="message">Message</Label>
|
||||
<Textarea placeholder="Type your message here." id="message" />
|
||||
</div>
|
||||
<Button className="w-full">Send Message</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Contact };
|
||||
133
components/landing/features.tsx
Normal file
133
components/landing/features.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Bell,
|
||||
UserCheck,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { ShimmeringText } from "@/components/ui/shimmering-text";
|
||||
|
||||
const Features = () => {
|
||||
const services = [
|
||||
{
|
||||
icon: <Calendar className="h-6 w-6" />,
|
||||
title: "Easy Online Booking",
|
||||
description:
|
||||
"Book your dental appointment online anytime, anywhere. Choose your preferred date, time slot, and specific dental service with real-time availability.",
|
||||
items: [
|
||||
"Real-time Availability",
|
||||
"Choose Date & Time",
|
||||
"Service Selection",
|
||||
],
|
||||
gradient: "from-indigo-500 to-blue-500",
|
||||
bgColor: "bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-indigo-950/30 dark:to-blue-950/30",
|
||||
iconBg: "bg-gradient-to-br from-indigo-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
icon: <UserCheck className="h-6 w-6" />,
|
||||
title: "Secure Patient Portal",
|
||||
description:
|
||||
"Create your secure account with email verification. Manage your profile, medical history, and view all your appointments in one dashboard.",
|
||||
items: ["Profile Management", "Medical History", "Appointment Overview"],
|
||||
gradient: "from-teal-500 to-cyan-500",
|
||||
bgColor: "bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-teal-950/30 dark:to-cyan-950/30",
|
||||
iconBg: "bg-gradient-to-br from-teal-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-6 w-6" />,
|
||||
title: "Smart Reminders",
|
||||
description:
|
||||
"Never miss an appointment with automatic email and SMS reminders. Stay informed about upcoming visits and important updates.",
|
||||
items: ["Email Notifications", "SMS Reminders", "Real-time Updates"],
|
||||
gradient: "from-violet-500 to-purple-500",
|
||||
bgColor: "bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30",
|
||||
iconBg: "bg-gradient-to-br from-violet-500 to-purple-500",
|
||||
},
|
||||
{
|
||||
icon: <CreditCard className="h-6 w-6" />,
|
||||
title: "Flexible Payments",
|
||||
description:
|
||||
"Pay consultation and booking fees conveniently online via credit card, e-wallet, or bank transfer. Secure and hassle-free transactions.",
|
||||
items: ["Multiple Payment Methods", "Secure Checkout", "Payment History"],
|
||||
gradient: "from-emerald-500 to-green-500",
|
||||
bgColor: "bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-950/30 dark:to-green-950/30",
|
||||
iconBg: "bg-gradient-to-br from-emerald-500 to-green-500",
|
||||
},
|
||||
{
|
||||
icon: <Clock className="h-6 w-6" />,
|
||||
title: "Appointment Management",
|
||||
description:
|
||||
"Full control over your appointments. View, reschedule, or cancel upcoming visits easily through your patient dashboard.",
|
||||
items: ["View Appointments", "Reschedule Anytime", "Easy Cancellation"],
|
||||
gradient: "from-amber-500 to-orange-500",
|
||||
bgColor: "bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30",
|
||||
iconBg: "bg-gradient-to-br from-amber-500 to-orange-500",
|
||||
},
|
||||
{
|
||||
icon: <Search className="h-6 w-6" />,
|
||||
title: "Find Your Dentist",
|
||||
description:
|
||||
"Search for dentists by specialty or service. View detailed profiles with qualifications, experience, and patient reviews to make informed decisions.",
|
||||
items: ["Dentist Profiles", "Read Reviews", "Compare Specialists"],
|
||||
gradient: "from-pink-500 to-rose-500",
|
||||
bgColor: "bg-gradient-to-br from-pink-50 to-rose-50 dark:from-pink-950/30 dark:to-rose-950/30",
|
||||
iconBg: "bg-gradient-to-br from-pink-500 to-rose-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-6xl space-y-12">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
<ShimmeringText
|
||||
text="Features"
|
||||
className="bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
|
||||
shimmeringColor="rgb(147 51 234)"
|
||||
color="rgb(79 70 229)"
|
||||
duration={2}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Everything you need to manage your dental health journey. Book
|
||||
appointments, track your history, and connect with top dentists.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${service.bgColor} space-y-6 rounded-xl border p-8 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${service.iconBg} text-white rounded-full p-3 shadow-lg`}>
|
||||
{service.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">{service.title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{service.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full bg-gradient-to-r ${service.gradient}`} />
|
||||
<span className="text-sm font-medium">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Features };
|
||||
148
components/landing/footer.tsx
Normal file
148
components/landing/footer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { FaFacebook, FaInstagram } from "react-icons/fa";
|
||||
import Image from "next/image";
|
||||
interface Footer7Props {
|
||||
logo?: {
|
||||
url: string;
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string;
|
||||
};
|
||||
sections?: Array<{
|
||||
title: string;
|
||||
links: Array<{ name: string; href: string }>;
|
||||
}>;
|
||||
description?: string;
|
||||
socialLinks?: Array<{
|
||||
icon: React.ReactElement;
|
||||
href: string;
|
||||
label: string;
|
||||
}>;
|
||||
copyright?: string;
|
||||
legalLinks?: Array<{
|
||||
name: string;
|
||||
href: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const defaultSections = [
|
||||
{
|
||||
title: "Services",
|
||||
links: [
|
||||
{ name: "Preventive Care", href: "/services/preventive-care" },
|
||||
{ name: "Cosmetic Dentistry", href: "/services/cosmetic-dentistry" },
|
||||
{ name: "Orthodontics", href: "/services/orthodontics" },
|
||||
{ name: "Pediatric Dentistry", href: "/services/pediatric-dentistry" },
|
||||
{ name: "Emergency Care", href: "/services/emergency-care" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Patient Resources",
|
||||
links: [
|
||||
{ name: "Book Appointment", href: "/book" },
|
||||
{ name: "Patient Portal", href: "/dashboard" },
|
||||
{ name: "Insurance Info", href: "/insurance" },
|
||||
{ name: "Financing Options", href: "/financing" },
|
||||
{ name: "New Patient Forms", href: "/forms" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "About Us",
|
||||
links: [
|
||||
{ name: "Our Team", href: "/team" },
|
||||
{ name: "Our Clinic", href: "/clinic" },
|
||||
{ name: "Contact Us", href: "/contact" },
|
||||
{ name: "Careers", href: "/careers" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultSocialLinks = [
|
||||
{ icon: <FaInstagram className="size-5" />, href: "#", label: "Instagram" },
|
||||
{ icon: <FaFacebook className="size-5" />, href: "#", label: "Facebook" },
|
||||
];
|
||||
|
||||
const defaultLegalLinks = [
|
||||
{ name: "Terms and Conditions", href: "/docs/terms-and-conditions" },
|
||||
{ name: "Privacy Policy", href: "/docs/privacy-policy" },
|
||||
];
|
||||
|
||||
const Footer = ({
|
||||
logo = {
|
||||
url: "/",
|
||||
src: "/tooth.svg",
|
||||
alt: "Dental U Care Logo",
|
||||
title: "Dental U Care",
|
||||
},
|
||||
sections = defaultSections,
|
||||
description = "Your trusted online dental appointment system. Book appointments, manage your dental health, and connect with expert dentists - all in one place.",
|
||||
socialLinks = defaultSocialLinks,
|
||||
copyright = "© 2025 Dental U Care. All rights reserved.",
|
||||
legalLinks = defaultLegalLinks,
|
||||
}: Footer7Props) => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="flex w-full flex-col justify-between gap-10 lg:flex-row lg:items-start lg:text-left">
|
||||
<div className="flex w-full flex-col justify-between gap-6 lg:items-start">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 lg:justify-start">
|
||||
<a href={logo.url}>
|
||||
<Image
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
title={logo.title}
|
||||
className="h-8"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
</a>
|
||||
<h2 className="text-xl font-semibold">{logo.title}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-[70%] text-sm">
|
||||
{description}
|
||||
</p>
|
||||
<ul className="text-muted-foreground flex items-center space-x-6">
|
||||
{socialLinks.map((social, idx) => (
|
||||
<li key={idx} className="hover:text-primary font-medium">
|
||||
<a href={social.href} aria-label={social.label}>
|
||||
{social.icon}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="grid w-full gap-6 md:grid-cols-3 lg:gap-20">
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx}>
|
||||
<h3 className="mb-4 font-bold">{section.title}</h3>
|
||||
<ul className="text-muted-foreground space-y-3 text-sm">
|
||||
{section.links.map((link, linkIdx) => (
|
||||
<li
|
||||
key={linkIdx}
|
||||
className="hover:text-primary font-medium"
|
||||
>
|
||||
<a href={link.href}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-8 flex flex-col justify-between gap-4 border-t py-8 text-xs font-medium md:flex-row md:items-center md:text-left">
|
||||
<p className="order-2 lg:order-1">{copyright}</p>
|
||||
<ul className="order-1 flex flex-col gap-2 md:order-2 md:flex-row">
|
||||
{legalLinks.map((link, idx) => (
|
||||
<li key={idx} className="hover:text-primary font-medium">
|
||||
<a href={link.href}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Footer };
|
||||
101
components/landing/get-started.tsx
Normal file
101
components/landing/get-started.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
import React from "react"
|
||||
|
||||
export default function GetStartedGuide() {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto py-10 px-4">
|
||||
<h1 className="text-3xl font-bold mb-4 text-center">Dental U Care – Get Started Guide</h1>
|
||||
<p className="mb-6 text-center">Welcome to Dental U Care!<br />We’re excited to help you on your journey toward better oral health. Here’s how to get started as a new patient or team member.</p>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-2">For Patients</h2>
|
||||
<ol className="list-decimal list-inside space-y-4">
|
||||
<li>
|
||||
<strong>Register or Book Online</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Visit our website/app and click on “Book Appointment” or “Register as New Patient.”</li>
|
||||
<li>Fill in your details: Name, email, contact number, and medical history.</li>
|
||||
<li>Choose your preferred date, time, and service (e.g., cleaning, checkup, whitening).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Confirmation & Reminders</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>After booking, you’ll receive a confirmation via email or SMS with your appointment details.</li>
|
||||
<li>You can reschedule or cancel anytime via the link in your confirmation message.</li>
|
||||
<li>We’ll send you reminders 24 hours before your appointment.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Before Your Visit</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Bring a valid ID and your insurance card (if applicable).</li>
|
||||
<li>Arrive 10 minutes early for check-in and medical history review.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>On Your Appointment Day</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Our friendly staff will guide you through your visit.</li>
|
||||
<li>Feel free to ask about treatment plans or payment options.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Aftercare and Follow-Up</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>You may receive aftercare instructions by email or SMS.</li>
|
||||
<li>Book your next visit directly from our website or app whenever you’re ready.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-2">For Staff</h2>
|
||||
<ol className="list-decimal list-inside space-y-4">
|
||||
<li>
|
||||
<strong>Secure Login</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Use your assigned credentials to log in to the Dental U Care admin portal.</li>
|
||||
<li>Update your profile and verify your contact information.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Dashboard Overview</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Access the appointments dashboard to see upcoming bookings.</li>
|
||||
<li>Use the patient records section to review medical histories or notes.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Managing Appointments</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Confirm, reschedule, or cancel bookings as needed.</li>
|
||||
<li>Send reminders and follow-up messages to patients with a single click.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Treatment Documentation</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Log treatments, prescribe medicines, and upload documents directly to each patient record.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Communication</strong>
|
||||
<ul className="list-disc list-inside ml-5 mt-1">
|
||||
<li>Use the messaging tools to answer patient queries or coordinate internally.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 mt-8">
|
||||
<h2 className="text-xl font-semibold mb-2">Need Help?</h2>
|
||||
<ul className="list-disc list-inside ml-5">
|
||||
<li>Call our help desk at <span className="font-medium">6-1234-5678</span>, or email <span className="font-medium">support@dentalucare.com</span>.</li>
|
||||
<li>Visit our FAQ on the website for common questions about appointments, insurance, or care tips.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
97
components/landing/hero.tsx
Normal file
97
components/landing/hero.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { ArrowRight, Calendar} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShimmeringText } from "@/components/ui/shimmering-text";
|
||||
import TypingText from "@/components/ui/typing-text";
|
||||
import Link from "next/link";
|
||||
const Hero = () => {
|
||||
return (
|
||||
<section>
|
||||
<div className="container mt-5 ">
|
||||
<div className="bg-muted/25 grid items-center gap-8 lg:grid-cols-2 border rounded-lg shadow-lg">
|
||||
<div className="flex flex-col items-center p-16 text-center lg:items-start lg:text-left">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care Logo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<ShimmeringText
|
||||
text="Dental U-Care"
|
||||
className="text-2xl font-bold"
|
||||
shimmeringColor="rgb(147 51 234)"
|
||||
color="rgb(37 99 235)"
|
||||
duration={2}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="my-6 text-pretty text-4xl font-bold lg:text-6xl min-h-[120px] lg:min-h-[160px] flex items-start">
|
||||
<TypingText
|
||||
text={[
|
||||
"Your Smile, Our Priority",
|
||||
"Book Appointments Online",
|
||||
"Smile with Confidence",
|
||||
"Expert Dental Care for you"
|
||||
]}
|
||||
typingSpeed={80}
|
||||
deletingSpeed={50}
|
||||
pauseDuration={2000}
|
||||
loop={true}
|
||||
showCursor={true}
|
||||
cursorClassName="bg-primary"
|
||||
cursorCharacter="|"
|
||||
className="inline-block"
|
||||
/>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-8 max-w-xl lg:text-xl">
|
||||
Book your dental appointment online in minutes. Choose your
|
||||
preferred date, time, and service. Real-time availability with
|
||||
instant confirmation.
|
||||
</p>
|
||||
<div className="flex w-full flex-col justify-center gap-2 sm:flex-row lg:justify-start">
|
||||
<Button size="lg">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Book Appointment Now
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="lg">
|
||||
<Link href="/get-started" className="flex items-center">
|
||||
Get Started
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-3 gap-6 text-center lg:text-left">
|
||||
<div>
|
||||
<p className="text-2xl font-bold">500+</p>
|
||||
<p className="text-muted-foreground text-xs">Happy Patients</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">4</p>
|
||||
<p className="text-muted-foreground text-xs">Expert Dentists</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">98%</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Satisfaction Rate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden rounded-r-lg sm:rounded-lg md:rounded-lg">
|
||||
<Image
|
||||
src="/smile.jpg"
|
||||
alt="Professional dental care at Dental U Care"
|
||||
width={600}
|
||||
height={500}
|
||||
priority
|
||||
className="h-full w-full object-cover dark:brightness-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Hero };
|
||||
31
components/landing/navbar-wrapper.tsx
Normal file
31
components/landing/navbar-wrapper.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import { Navbar } from "./navbar";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function NavbarWrapper() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [isUserAdmin, setIsUserAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchUser() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session");
|
||||
if (res.ok) {
|
||||
const session = await res.json();
|
||||
setUser(session.user);
|
||||
setIsUserAdmin(session.user?.role === "admin");
|
||||
} else {
|
||||
setUser(null);
|
||||
setIsUserAdmin(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("NavbarWrapper: Error fetching session", error);
|
||||
setUser(null);
|
||||
setIsUserAdmin(false);
|
||||
}
|
||||
}
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
return <Navbar user={user} isAdmin={isUserAdmin} />;
|
||||
}
|
||||
651
components/landing/navbar.tsx
Normal file
651
components/landing/navbar.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
"use client";
|
||||
import { MenuIcon, SearchIcon, LogOut, User, Shield } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import Image from "next/image";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { ModeToggle } from "../ui/mode-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string;
|
||||
} | null;
|
||||
|
||||
type NavbarProps = {
|
||||
user?: User;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
const Navbar = ({ user, isAdmin: userIsAdmin }: NavbarProps) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [showRequiredDialog, setShowRequiredDialog] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const handleSignOut = async () => {
|
||||
setShowLogoutDialog(false);
|
||||
try {
|
||||
await authClient.signOut();
|
||||
toast.success("Signed out successfully");
|
||||
router.push("/sign-in");
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Failed to sign out");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle search submit
|
||||
const handleSearch = (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (searchValue.trim()) {
|
||||
router.push(`/search?query=${encodeURIComponent(searchValue.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
const Services = [
|
||||
{
|
||||
title: "Preventive Care",
|
||||
description:
|
||||
"Cleanings, exams, and routine check-ups to keep smiles healthy",
|
||||
href: "/services/preventive-care",
|
||||
},
|
||||
{
|
||||
title: "Cosmetic Dentistry",
|
||||
description: "Teeth whitening, veneers, and smile makeovers",
|
||||
href: "/services/cosmetic-dentistry",
|
||||
},
|
||||
{
|
||||
title: "Orthodontics",
|
||||
description: "Braces and clear aligners for children and adults",
|
||||
href: "/services/orthodontics",
|
||||
},
|
||||
{
|
||||
title: "Pediatric Dentistry",
|
||||
description: "Gentle, kid-friendly dental care for your little ones",
|
||||
href: "/services/pediatric-dentistry",
|
||||
},
|
||||
{
|
||||
title: "Emergency Care",
|
||||
description:
|
||||
"Same-day treatment for tooth pain, injuries, and urgent issues",
|
||||
href: "/services/emergency-care",
|
||||
},
|
||||
{
|
||||
title: "Patient Resources",
|
||||
description: "New patient forms, insurance info, and financing options",
|
||||
href: "/patient-resources",
|
||||
},
|
||||
];
|
||||
|
||||
// Filtered suggestions based on searchValue
|
||||
const suggestions =
|
||||
searchValue.trim().length > 0
|
||||
? Services.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(searchValue.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
const aboutItems = [
|
||||
{
|
||||
title: "Our Story",
|
||||
description: "Learn about Dental U Care's mission and values",
|
||||
href: "/#about",
|
||||
},
|
||||
{
|
||||
title: "Our Team",
|
||||
description: "Meet our expert dental professionals",
|
||||
href: "/#team",
|
||||
},
|
||||
{
|
||||
title: "Features",
|
||||
description: "Discover our online booking system features",
|
||||
href: "/#features",
|
||||
},
|
||||
{
|
||||
title: "Pricing",
|
||||
description: "Transparent pricing for all dental services",
|
||||
href: "/#pricing",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="sticky top-0 z-50 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"container transition-all duration-300",
|
||||
isScrolled && "px-6 lg:px-12"
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-full px-6 py-6 transition-all duration-300 ",
|
||||
isScrolled
|
||||
? "border-2 border-accent dark:border-gray-900 bg-background/80 shadow-lg"
|
||||
: "border-2 border-accent dark:border-gray-800 bg-background/80 shadow-lg"
|
||||
)}
|
||||
>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<span className="text-2xl font-semibold bg-gradient-to-r from-blue-600 to-blue-800 text-transparent bg-clip-text tracking-tighter">
|
||||
Dental U Care
|
||||
</span>
|
||||
</Link>
|
||||
<NavigationMenu className="hidden lg:block">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent">
|
||||
About
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<div className="grid w-[500px] grid-cols-2 p-3">
|
||||
{aboutItems.map((item, index) => (
|
||||
<NavigationMenuLink
|
||||
href={item.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={item.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</NavigationMenuLink>
|
||||
))}
|
||||
</div>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="bg-transparent hover:bg-transparent focus:bg-transparent data-[active]:bg-transparent data-[state=open]:bg-transparent">
|
||||
Services
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<div className="grid w-[600px] grid-cols-2 p-3">
|
||||
{Services.map((service, index) => (
|
||||
<NavigationMenuLink
|
||||
href={service.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={service.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{service.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</NavigationMenuLink>
|
||||
))}
|
||||
</div>
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href="/#pricing"
|
||||
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all hover:text-accent-foreground hover:border-b-2 hover:border-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Pricing
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href="/#contact"
|
||||
className="group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-all hover:text-accent-foreground hover:border-b-2 hover:border-primary focus:outline-none disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
Contact
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
<div className="hidden items-center gap-4 lg:flex">
|
||||
<div className="relative">
|
||||
<form onSubmit={handleSearch} className="contents">
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"w-64 transition-all duration-300 border border-gray-300 hover:border-primary hover:shadow-sm dark:border-gray-700 dark:hover:border-primary rounded-md",
|
||||
isScrolled && "w-58"
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
ref={inputRef}
|
||||
placeholder="Search services..."
|
||||
className="border-0 focus-visible:ring-0"
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
setSearchValue(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onBlur={() =>
|
||||
setTimeout(() => setShowSuggestions(false), 100)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSearch();
|
||||
}}
|
||||
aria-label="Search services"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="sm" type="submit">
|
||||
Search
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className="absolute left-0 z-50 mt-1 w-full bg-background border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-56 overflow-auto">
|
||||
{suggestions.map((s) => (
|
||||
<li
|
||||
key={s.title}
|
||||
className="px-4 py-2 cursor-pointer hover:bg-accent"
|
||||
onMouseDown={() => {
|
||||
setShowSuggestions(false);
|
||||
setSearchValue("");
|
||||
router.push(s.href);
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold">{s.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<Button
|
||||
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
|
||||
asChild
|
||||
>
|
||||
<Link href="/patient/book-appointment">Book Now</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={user.image || undefined}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{userIsAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{user?.role === "dentist" && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dentist" className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{user?.role === "patient" && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/patient" className="cursor-pointer">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
className="cursor-pointer text-red-600"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
|
||||
>
|
||||
<Link href="/sign-in">Sign In</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(isScrolled ? "hidden" : "lg:inline-flex")}
|
||||
onClick={() => setShowRequiredDialog(true)}
|
||||
>
|
||||
Book Now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild className="lg:hidden">
|
||||
<Button variant="outline" size="icon">
|
||||
<MenuIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="top" className="max-h-screen overflow-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<a href="#" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<span className="text-lg font-semibold tracking-tighter">
|
||||
Dental U Care
|
||||
</span>
|
||||
</a>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col p-4">
|
||||
<Accordion type="single" collapsible className="mb-2 mt-4">
|
||||
<AccordionItem value="about" className="border-none">
|
||||
<AccordionTrigger className="text-base hover:no-underline">
|
||||
About
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid gap-2">
|
||||
{aboutItems.map((item, index) => (
|
||||
<a
|
||||
href={item.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={item.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="solutions" className="border-none">
|
||||
<AccordionTrigger className="text-base hover:no-underline">
|
||||
Services
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid md:grid-cols-2">
|
||||
{Services.map((service, index) => (
|
||||
<a
|
||||
href={service.href}
|
||||
key={index}
|
||||
className="rounded-md p-3 transition-colors"
|
||||
>
|
||||
<div key={service.title}>
|
||||
<p className="text-foreground mb-1 font-semibold">
|
||||
{service.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link href="/#contact" className="font-medium">
|
||||
Contact
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
{user ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-4">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={user.image || undefined}
|
||||
alt={user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground mt-1">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/patient/book-appointment">Book Now</Link>
|
||||
</Button>
|
||||
{userIsAdmin && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Admin Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{user?.role === "dentist" && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/dentist">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Dentist Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{user?.role === "patient" && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/patient">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline">
|
||||
<Link href="/sign-in">Sign in</Link>
|
||||
</Button>
|
||||
<Button onClick={() => setShowRequiredDialog(true)}>
|
||||
Book Now
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</nav>
|
||||
</div>
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log out</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to log out?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button variant="destructive" onClick={handleSignOut}>
|
||||
Log out
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showRequiredDialog} onOpenChange={setShowRequiredDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sign in required</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to sign in to book an appointment. Would you like to sign
|
||||
in now?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowRequiredDialog(false);
|
||||
router.push("/sign-in");
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Navbar };
|
||||
286
components/landing/pricing.tsx
Normal file
286
components/landing/pricing.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
interface Service {
|
||||
name: string;
|
||||
price: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ServiceCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
badge: string;
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
const Pricing = () => {
|
||||
const [showRequiredDialog, setShowRequiredDialog] = useState(false);
|
||||
|
||||
const serviceCategories: ServiceCategory[] = [
|
||||
{
|
||||
id: "basic",
|
||||
title: "Basic Services",
|
||||
badge: "Essential",
|
||||
services: [
|
||||
{
|
||||
name: "Dental Consultation / Checkup",
|
||||
price: "₱500 – ₱1,500",
|
||||
description: "Basic dental examination to check overall condition of teeth and gums",
|
||||
},
|
||||
{
|
||||
name: "Oral Prophylaxis (Cleaning)",
|
||||
price: "₱1,200 – ₱3,000",
|
||||
description: "Regular teeth cleaning, recommended every 6 months",
|
||||
},
|
||||
{
|
||||
name: "Tooth X-Ray",
|
||||
price: "₱700 – ₱2,500+",
|
||||
description: "Depends on type (periapical, panoramic, etc.)",
|
||||
},
|
||||
{
|
||||
name: "Simple Tooth Extraction",
|
||||
price: "₱1,500 – ₱5,000",
|
||||
description: "Basic tooth extraction procedure",
|
||||
},
|
||||
{
|
||||
name: "Deep Cleaning / Scaling and Root Planing",
|
||||
price: "₱3,000 – ₱10,000+",
|
||||
description: "For early signs of gum disease, deeper cleaning procedure",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fillings",
|
||||
title: "Dental Fillings",
|
||||
badge: "Restorative",
|
||||
services: [
|
||||
{
|
||||
name: "Amalgam Filling (Silver)",
|
||||
price: "₱800 – ₱2,500",
|
||||
description: "Traditional silver-colored filling material",
|
||||
},
|
||||
{
|
||||
name: "Composite Filling (Tooth-colored)",
|
||||
price: "₱1,500 – ₱4,500+",
|
||||
description: "Natural-looking tooth-colored filling",
|
||||
},
|
||||
{
|
||||
name: "Ceramic/Gold Filling",
|
||||
price: "₱5,000 – ₱15,000+",
|
||||
description: "Premium filling materials for durability",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "advanced",
|
||||
title: "Advanced Treatments",
|
||||
badge: "Popular",
|
||||
services: [
|
||||
{
|
||||
name: "Surgical/Impacted Tooth Extraction",
|
||||
price: "₱10,000 – ₱30,000+",
|
||||
description: "Complex extraction for impacted teeth",
|
||||
},
|
||||
{
|
||||
name: "Root Canal Treatment",
|
||||
price: "₱5,000 – ₱20,000+",
|
||||
description: "Treatment for infected tooth pulp, cleaned and sealed",
|
||||
},
|
||||
{
|
||||
name: "Dental Crowns (Basic - Metal or PFM)",
|
||||
price: "₱8,000 – ₱20,000+",
|
||||
description: "Cap for damaged tooth, metal or porcelain-fused-to-metal",
|
||||
},
|
||||
{
|
||||
name: "Dental Crowns (Premium - Zirconia, Emax)",
|
||||
price: "₱30,000 – ₱45,000+",
|
||||
description: "High-quality aesthetic crowns",
|
||||
},
|
||||
{
|
||||
name: "Teeth Whitening (Bleaching)",
|
||||
price: "₱9,000 – ₱30,000+",
|
||||
description: "Laser or in-clinic whitening procedure",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "replacement",
|
||||
title: "Tooth Replacement",
|
||||
badge: "Restoration",
|
||||
services: [
|
||||
{
|
||||
name: "Partial Denture",
|
||||
price: "₱10,000 – ₱30,000+",
|
||||
description: "Removable denture for missing teeth",
|
||||
},
|
||||
{
|
||||
name: "Full Denture",
|
||||
price: "Contact for pricing",
|
||||
description: "Complete denture set, depends on number of teeth",
|
||||
},
|
||||
{
|
||||
name: "Dental Bridges",
|
||||
price: "₱20,000 – ₱60,000+",
|
||||
description: "Replacement of missing teeth using adjacent teeth",
|
||||
},
|
||||
{
|
||||
name: "Dental Implants",
|
||||
price: "₱80,000 – ₱150,000+",
|
||||
description: "Permanent tooth replacement using titanium post + crown",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cosmetic",
|
||||
title: "Cosmetic & Orthodontics",
|
||||
badge: "Premium",
|
||||
services: [
|
||||
{
|
||||
name: "Dental Veneers",
|
||||
price: "₱12,000 – ₱35,000+ per tooth",
|
||||
description: "For aesthetic purposes - straight, white, beautiful teeth",
|
||||
},
|
||||
{
|
||||
name: "Traditional Metal Braces",
|
||||
price: "₱35,000 – ₱80,000+",
|
||||
description: "Classic metal braces for teeth alignment",
|
||||
},
|
||||
{
|
||||
name: "Ceramic / Clear Braces",
|
||||
price: "₱100,000 – ₱200,000+",
|
||||
description: "Aesthetic clear or tooth-colored braces",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-32 ml-10">
|
||||
<div className="container">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-pretty text-4xl font-bold lg:text-6xl">
|
||||
Dental Services & Pricing
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-3xl mx-auto lg:text-xl">
|
||||
Transparent pricing for all your dental needs. Quality dental care at competitive rates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5 h-auto gap-2">
|
||||
{serviceCategories.map((category) => (
|
||||
<TabsTrigger
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
className="text-sm sm:text-base"
|
||||
>
|
||||
{category.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{serviceCategories.map((category) => (
|
||||
<TabsContent
|
||||
key={category.id}
|
||||
value={category.id}
|
||||
className="mt-8 animate-in fade-in-50 slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl lg:text-3xl">{category.title}</CardTitle>
|
||||
<Badge className="uppercase">{category.badge}</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Professional dental services with transparent pricing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{category.services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between p-4 rounded-lg border hover:border-primary transition-all duration-300 hover:shadow-md gap-2 animate-in fade-in-50 slide-in-from-left-4"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<Check className="size-5 text-primary mt-1 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{service.name}</h3>
|
||||
{service.description && (
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right sm:text-left sm:ml-4">
|
||||
<span className="text-xl font-bold text-primary whitespace-nowrap">
|
||||
{service.price}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center animate-in fade-in-50 zoom-in-95 duration-500 delay-300">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowRequiredDialog(true)}
|
||||
>
|
||||
<Link href="patient/book-appointment" className="flex items-center">
|
||||
Book Appointment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={showRequiredDialog} onOpenChange={setShowRequiredDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Required To Sign In & Sign Up</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please sign in or sign up to access this content.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline"><Link href="/sign-in">Sign In</Link></Button>
|
||||
</DialogClose>
|
||||
<Button variant="default">
|
||||
<Link href="/sign-up">Sign Up</Link>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Pricing };
|
||||
83
components/landing/privacy-policy.tsx
Normal file
83
components/landing/privacy-policy.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
|
||||
<h1 className="text-3xl font-bold mb-4 text-center">Privacy Policy</h1>
|
||||
<h2 className="text-xl font-semibold mb-2 text-center">Dental U Care</h2>
|
||||
<p className="mb-6 text-center">
|
||||
Dental U Care values your privacy and is committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect your data when you visit our clinic, website, or contact us through any communication channel.
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 space-y-4">
|
||||
<li>
|
||||
<strong>Information We Collect</strong><br />
|
||||
We collect the following types of information to deliver quality dental care and manage our services effectively:<br />
|
||||
<ul className="list-disc pl-6">
|
||||
<li>Personal Information: Name, address, contact number, email, and date of birth.</li>
|
||||
<li>Medical and Dental Information: Health history, dental records, X-rays, treatment notes, and insurance details.</li>
|
||||
<li>Payment Information: Billing addresses, payment records, and insurance claim details.</li>
|
||||
<li>Digital Information: Information from your visits to our website, including cookies, browser type, and access time (if applicable).</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>How We Use Your Information</strong><br />
|
||||
Your information is used solely for legitimate purposes related to your treatment and clinic operations:<br />
|
||||
<ul className="list-disc pl-6">
|
||||
<li>To provide dental diagnosis, care, and follow-up services.</li>
|
||||
<li>To maintain accurate internal records and manage appointments.</li>
|
||||
<li>To process billing, payments, and insurance claims.</li>
|
||||
<li>To communicate important updates, reminders, and follow-up instructions.</li>
|
||||
<li>To improve the quality and safety of our services.</li>
|
||||
<li>To comply with local laws, regulations, or legal obligations.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Data Protection and Security</strong><br />
|
||||
We implement security measures to protect your personal and health information from unauthorized access, misuse, or disclosure.<br />
|
||||
Patient records are stored securely in electronic or physical form and accessed only by authorized personnel.<br />
|
||||
Access to data is strictly limited to staff members who require it for care delivery or administrative purposes.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Information Sharing</strong><br />
|
||||
Dental U Care respects the confidentiality of your information. We do not sell or rent patient data. Information may be shared only:<br />
|
||||
<ul className="list-disc pl-6">
|
||||
<li>With authorized healthcare professionals involved in your care.</li>
|
||||
<li>With insurance providers for claim processing.</li>
|
||||
<li>With laboratories or specialists when necessary for your treatment.</li>
|
||||
<li>As required by law, such as health or regulatory reporting.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Retention of Records</strong><br />
|
||||
Patient records are retained for as long as required by Philippine law or professional standards. Once retention periods expire, records are securely disposed of.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Your Rights</strong><br />
|
||||
You have the right to:
|
||||
<ul className="list-disc pl-6">
|
||||
<li>Access and review your dental records.</li>
|
||||
<li>Request correction of inaccurate or incomplete data.</li>
|
||||
<li>Withdraw consent for certain uses, subject to legal and contractual limitations.</li>
|
||||
<li>File a privacy-related complaint with the clinic management.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Cookies and Website Data (if applicable)</strong><br />
|
||||
If you use our website, cookies may be used to enhance your browsing experience. You can disable cookies through your browser settings, but some features may not function properly.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Policy Updates</strong><br />
|
||||
Dental U Care may update this Privacy Policy from time to time to comply with updated legal requirements or improve data management. Revisions will take effect immediately upon posting.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Contact Information</strong><br />
|
||||
For questions, requests, or concerns regarding this Privacy Policy, please contact:<br />
|
||||
Dental U Care<br />
|
||||
Address: <span className="italic">[Baltan Street, Puerto Princesa City, Palawan]</span><br />
|
||||
Phone: <span className="italic">[63+ 1234 5678]</span><br />
|
||||
Email: <span className="italic">[info@dentalucare.com]</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
132
components/landing/services.tsx
Normal file
132
components/landing/services.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Stethoscope,
|
||||
Sparkles,
|
||||
Brackets,
|
||||
Drill,
|
||||
Baby,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import { ShimmeringText } from "@/components/ui/shimmering-text";
|
||||
|
||||
const Services = () => {
|
||||
const services = [
|
||||
{
|
||||
icon: <Stethoscope className="h-6 w-6" />,
|
||||
title: "General Dentistry",
|
||||
description:
|
||||
"Comprehensive oral health care including routine checkups, professional cleanings, and preventive treatments to maintain your dental health.",
|
||||
items: [
|
||||
"Routine Checkups",
|
||||
"Professional Cleaning",
|
||||
"Cavity Fillings",
|
||||
],
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
bgColor: "bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/30 dark:to-cyan-950/30",
|
||||
iconBg: "bg-gradient-to-br from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-6 w-6" />,
|
||||
title: "Cosmetic Dentistry",
|
||||
description:
|
||||
"Transform your smile with our advanced cosmetic procedures. From teeth whitening to complete smile makeovers, we help you achieve the perfect smile.",
|
||||
items: ["Teeth Whitening", "Veneers", "Smile Makeover"],
|
||||
gradient: "from-purple-500 to-pink-500",
|
||||
bgColor: "bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/30 dark:to-pink-950/30",
|
||||
iconBg: "bg-gradient-to-br from-purple-500 to-pink-500",
|
||||
},
|
||||
{
|
||||
icon: <Brackets className="h-6 w-6" />,
|
||||
title: "Orthodontics",
|
||||
description:
|
||||
"Straighten your teeth and correct bite issues with traditional braces or modern clear aligners for both children and adults.",
|
||||
items: ["Traditional Braces", "Clear Aligners", "Retainers"],
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
bgColor: "bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30",
|
||||
iconBg: "bg-gradient-to-br from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
icon: <Drill className="h-6 w-6" />,
|
||||
title: "Restorative Care",
|
||||
description:
|
||||
"Restore damaged or missing teeth with dental implants, crowns, bridges, and root canal therapy using advanced techniques.",
|
||||
items: ["Dental Implants", "Crowns & Bridges", "Root Canal Therapy"],
|
||||
gradient: "from-orange-500 to-red-500",
|
||||
bgColor: "bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/30 dark:to-red-950/30",
|
||||
iconBg: "bg-gradient-to-br from-orange-500 to-red-500",
|
||||
},
|
||||
{
|
||||
icon: <Baby className="h-6 w-6" />,
|
||||
title: "Pediatric Dentistry",
|
||||
description:
|
||||
"Gentle, kid-friendly dental care designed to make children feel comfortable and establish healthy oral hygiene habits from an early age.",
|
||||
items: ["Children's Checkups", "Fluoride Treatment", "Sealants"],
|
||||
gradient: "from-yellow-500 to-amber-500",
|
||||
bgColor: "bg-gradient-to-br from-yellow-50 to-amber-50 dark:from-yellow-950/30 dark:to-amber-950/30",
|
||||
iconBg: "bg-gradient-to-br from-yellow-500 to-amber-500",
|
||||
},
|
||||
{
|
||||
icon: <ShieldAlert className="h-6 w-6" />,
|
||||
title: "Emergency Dental Care",
|
||||
description:
|
||||
"Same-day emergency services for dental injuries, severe toothaches, broken teeth, and other urgent dental issues.",
|
||||
items: ["Tooth Pain Relief", "Broken Tooth Repair", "Same-Day Treatment"],
|
||||
gradient: "from-rose-500 to-red-600",
|
||||
bgColor: "bg-gradient-to-br from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30",
|
||||
iconBg: "bg-gradient-to-br from-rose-500 to-red-600",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-6xl space-y-12">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
<ShimmeringText
|
||||
text="Our Dental Services"
|
||||
className="bg-gradient-to-r from-blue-600 via-purple-600 to-pink-600 bg-clip-text text-transparent"
|
||||
shimmeringColor="rgb(236 72 153)"
|
||||
color="rgb(37 99 235)"
|
||||
duration={2}
|
||||
/>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Comprehensive dental care tailored to your needs. From preventive treatments to advanced procedures, we provide exceptional care for the whole family.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
{services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${service.bgColor} space-y-6 rounded-xl border p-8 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${service.iconBg} text-white rounded-full p-3 shadow-lg`}>
|
||||
{service.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">{service.title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{service.items.map((item, itemIndex) => (
|
||||
<div key={itemIndex} className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 rounded-full bg-gradient-to-r ${service.gradient}`} />
|
||||
<span className="text-sm font-medium">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Services };
|
||||
129
components/landing/team.tsx
Normal file
129
components/landing/team.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Github, Linkedin, Twitter } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
avatar: string;
|
||||
github?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
}
|
||||
|
||||
interface Team1Props {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
description?: string;
|
||||
members?: TeamMember[];
|
||||
}
|
||||
|
||||
const Team = ({
|
||||
heading = "Meet Our Expert Dental Team",
|
||||
description = "Our team of highly qualified dentists and specialists are dedicated to providing you with exceptional dental care. Each brings years of experience and a passion for creating healthy, beautiful smiles.",
|
||||
members = [
|
||||
{
|
||||
id: "member-1",
|
||||
name: "Kath Estrada",
|
||||
role: "Chief Dentist & Orthodontist",
|
||||
avatar:
|
||||
"/kath.jpg",
|
||||
|
||||
},
|
||||
{
|
||||
id: "member-2",
|
||||
name: "Clyrelle Jade Cervantes",
|
||||
role: "Cosmetic Dentistry Specialist",
|
||||
avatar:
|
||||
" /cervs.jpg",
|
||||
},
|
||||
{
|
||||
id: "member-3",
|
||||
name: "Von Vryan Arguelles",
|
||||
role: "Oral Surgeon",
|
||||
avatar:
|
||||
"/von.jpg",
|
||||
|
||||
},
|
||||
{
|
||||
id: "member-4",
|
||||
name: "Dexter Cabanag",
|
||||
role: "Periodontist",
|
||||
avatar:
|
||||
"/dexter.jpg",
|
||||
|
||||
},
|
||||
],
|
||||
}: Team1Props) => {
|
||||
return (
|
||||
<section className="py-24 lg:py-32">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-6 text-3xl font-bold tracking-tight lg:text-5xl">
|
||||
{heading}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3 [&>*:last-child:nth-child(3n-2)]:col-start-2">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="border rounded-lg p-6 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-4">
|
||||
<Avatar className="size-20 lg:size-24">
|
||||
<AvatarImage src={member.avatar} />
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{member.name}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-1 text-lg font-semibold">{member.name}</h3>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{member.github && (
|
||||
<a
|
||||
href={member.github}
|
||||
className="bg-muted/50 rounded-lg p-2"
|
||||
>
|
||||
<Github className="text-muted-foreground size-4" />
|
||||
</a>
|
||||
)}
|
||||
{member.twitter && (
|
||||
<a
|
||||
href={member.twitter}
|
||||
className="bg-muted/50 rounded-lg p-2"
|
||||
>
|
||||
<Twitter className="text-muted-foreground size-4" />
|
||||
</a>
|
||||
)}
|
||||
{member.linkedin && (
|
||||
<a
|
||||
href={member.linkedin}
|
||||
className="bg-muted/50 rounded-lg p-2"
|
||||
>
|
||||
<Linkedin className="text-muted-foreground size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { Team };
|
||||
78
components/landing/terms-and-conditions.tsx
Normal file
78
components/landing/terms-and-conditions.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from "react";
|
||||
|
||||
export default function TermsAndConditions() {
|
||||
return (
|
||||
<section className="max-w-3xl mx-auto py-10 px-4 text-gray-800">
|
||||
<h1 className="text-3xl font-bold mb-4 text-center">Terms and Conditions</h1>
|
||||
<h2 className="text-xl font-semibold mb-2 text-center">Dental U Care</h2>
|
||||
<p className="mb-6 text-center">
|
||||
Welcome to Dental U Care. By accessing our website, booking an appointment, or receiving services at our clinic, you agree to the following Terms and Conditions. Please read them carefully before engaging with our services.
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 space-y-4">
|
||||
<li>
|
||||
<strong>General Terms</strong><br />
|
||||
Dental U Care provides dental healthcare services in accordance with professional and ethical standards.<br />
|
||||
These Terms apply to all patients, clients, and users who interact with our clinic, whether in person, by phone, or online.<br />
|
||||
We reserve the right to update these Terms at any time. Any changes will be posted on our premises or website.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Appointments and Cancellations</strong><br />
|
||||
Appointments can be booked via phone, online, or in person.<br />
|
||||
Please arrive at least 10 minutes before your scheduled time.<br />
|
||||
Appointment cancellations must be made at least 24 hours in advance. Late cancellations or missed appointments may result in a cancellation fee.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payments and Billing</strong><br />
|
||||
Fees for dental services are determined based on the procedure and disclosed prior to treatment whenever possible.<br />
|
||||
Payment is required at the time of service unless otherwise arranged.<br />
|
||||
We accept cash, major credit/debit cards, and approved insurance.<br />
|
||||
Any additional laboratory or specialist fees will be discussed before proceeding.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Insurance and Claims</strong><br />
|
||||
Dental U Care assists patients in processing insurance claims but is not responsible for approval or denial of coverage.<br />
|
||||
Patients remain fully responsible for any amount not covered by insurance.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Patient Responsibilities</strong><br />
|
||||
Patients must provide accurate and complete medical and dental history information.<br />
|
||||
Any changes to health status, medications, or allergies must be reported promptly.<br />
|
||||
Patients are expected to follow the dentist’s recommended care and post-treatment instructions.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Treatment Consent</strong><br />
|
||||
Before any procedure, your dentist will explain the diagnosis, treatment options, and estimated costs.<br />
|
||||
By agreeing to proceed, you acknowledge that you understand the risks and benefits of the treatment.<br />
|
||||
Written consent may be required for certain procedures.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Privacy and Confidentiality</strong><br />
|
||||
Dental U Care complies with applicable privacy laws regarding the protection of personal and medical information.<br />
|
||||
Your records will not be shared without your consent, except as required by law or for insurance processing.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Disclaimer of Liability</strong><br />
|
||||
While our professionals strive to provide high-quality care, results may vary depending on individual conditions.<br />
|
||||
Dental U Care is not liable for post-treatment complications that arise from failure to follow prescribed care or external factors beyond our control.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Intellectual Property</strong><br />
|
||||
All content on our website and marketing materials—including text, logos, and images—is owned by Dental U Care and may not be copied or reproduced without permission.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Governing Law</strong><br />
|
||||
These Terms are governed by and construed in accordance with the laws of the Republic of the Philippines.<br />
|
||||
Any disputes shall be handled within the proper courts of Puerto Princesa City, Palawan.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Contact Information</strong><br />
|
||||
For questions regarding these Terms, please contact:<br />
|
||||
Dental U Care<br />
|
||||
Address: <span className="italic">[Baltan Street, Puerto Princesa City, Palawan]</span><br />
|
||||
Phone: <span className="italic">[63+ 1234 5678]</span><br />
|
||||
Email: <span className="italic">[info@dentalucare.com]</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
235
components/layout/app-sidebar.tsx
Normal file
235
components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
IconFileDescription,
|
||||
IconHelp,
|
||||
IconListDetails,
|
||||
IconReport,
|
||||
IconSettings,
|
||||
IconUsers,
|
||||
IconStethoscope,
|
||||
IconCalendar,
|
||||
IconMedicalCross,
|
||||
IconUserCog,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { NavDocuments } from "@/components/layout/nav-documents";
|
||||
import { NavMain } from "@/components/layout/nav-main";
|
||||
import { NavSecondary } from "@/components/layout/nav-secondary";
|
||||
import { NavUser } from "@/components/layout/nav-user";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
const adminData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Appointments",
|
||||
url: "/admin/appointment-management",
|
||||
icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
title: "Patients",
|
||||
url: "/admin/patient-management",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{
|
||||
title: "Dentists",
|
||||
url: "/admin/dentist-management",
|
||||
icon: IconStethoscope,
|
||||
},
|
||||
{
|
||||
title: "Services",
|
||||
url: "/admin/service-management",
|
||||
icon: IconMedicalCross,
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "/admin/user-management",
|
||||
icon: IconUserCog,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Help & Support",
|
||||
url: "/admin/help-support",
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
documents: [
|
||||
{
|
||||
name: "Analytics",
|
||||
url: "/Dashboard",
|
||||
icon: IconChartBar,
|
||||
},
|
||||
{
|
||||
name: "Reports",
|
||||
url: "/Reports",
|
||||
icon: IconReport,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patientData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/patient",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "Book Appointment",
|
||||
url: "/patient/book-appointment",
|
||||
icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
title: "My Appointments",
|
||||
url: "/patient/appointments",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
{
|
||||
title: "Payments",
|
||||
url: "/patient/payments",
|
||||
icon: IconFileDescription,
|
||||
},
|
||||
{
|
||||
title: "Health Records",
|
||||
url: "/patient/health-records",
|
||||
icon: IconDatabase,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/patient/settings",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Help & Support",
|
||||
url: "#",
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
const dentistData = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/dentist",
|
||||
icon: IconDashboard,
|
||||
},
|
||||
{
|
||||
title: "My Appointments",
|
||||
url: "/dentist/appointments",
|
||||
icon: IconCalendar,
|
||||
},
|
||||
{
|
||||
title: "My Patients",
|
||||
url: "/dentist/patients",
|
||||
icon: IconUsers,
|
||||
},
|
||||
{
|
||||
title: "Schedule",
|
||||
url: "/dentist/schedule",
|
||||
icon: IconListDetails,
|
||||
},
|
||||
],
|
||||
navSecondary: [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/dentist/settings",
|
||||
icon: IconSettings,
|
||||
},
|
||||
{
|
||||
title: "Help & Support",
|
||||
url: "#",
|
||||
icon: IconHelp,
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
};
|
||||
|
||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||
user?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string | null;
|
||||
} | null;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function AppSidebar({ user, isAdmin, ...props }: AppSidebarProps) {
|
||||
// Determine which data to use based on user role
|
||||
const role = user?.role || "patient";
|
||||
const data =
|
||||
role === "admin"
|
||||
? adminData
|
||||
: role === "dentist"
|
||||
? dentistData
|
||||
: patientData;
|
||||
const homeUrl =
|
||||
role === "admin" ? "/admin" : role === "dentist" ? "/dentist" : "/";
|
||||
return (
|
||||
<>
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
>
|
||||
<Link href={homeUrl} className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/tooth.svg"
|
||||
alt="Dental U Care"
|
||||
width={24}
|
||||
height={24}
|
||||
className="!size-6"
|
||||
/>
|
||||
<span className="text-base font-semibold bg-gradient-to-r from-blue-600 to-pink-800 bg-clip-text text-transparent">
|
||||
Dental U-Care
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
{data.documents.length > 0 && <NavDocuments items={data.documents} />}
|
||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{user && <NavUser user={user} isAdmin={isAdmin} />}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
components/layout/auth-layout-redirect.tsx
Normal file
38
components/layout/auth-layout-redirect.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Client-side redirect for authenticated users visiting auth pages
|
||||
* This runs after page load to avoid SSR/client hydration conflicts
|
||||
*/
|
||||
export function AuthLayoutRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Check session on client side only
|
||||
const checkSession = async () => {
|
||||
const { data: session, error } = await authClient.getSession();
|
||||
|
||||
if (!error && session?.user) {
|
||||
const user = session.user as { role?: string };
|
||||
const role = user.role;
|
||||
|
||||
// Redirect based on role
|
||||
if (role === "admin") {
|
||||
router.replace("/admin");
|
||||
} else if (role === "dentist") {
|
||||
router.replace("/dentist");
|
||||
} else if (role === "patient") {
|
||||
router.replace("/patient");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkSession();
|
||||
}, [router]);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
51
components/layout/dashboard-layout.tsx
Normal file
51
components/layout/dashboard-layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { SiteHeader } from "@/components/layout/site-header";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type UserRole = "admin" | "dentist" | "patient";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null | undefined;
|
||||
image?: string | null;
|
||||
};
|
||||
role: UserRole;
|
||||
children: ReactNode;
|
||||
variant?: "inset" | "sidebar";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared dashboard layout component that wraps pages with SidebarProvider,
|
||||
* AppSidebar, and SiteHeader to reduce code duplication across pages.
|
||||
*/
|
||||
export function DashboardLayout({
|
||||
user,
|
||||
role,
|
||||
children,
|
||||
variant = "inset",
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 72)",
|
||||
"--header-height": "calc(var(--spacing) * 12)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant={variant} user={user} />
|
||||
<SidebarInset>
|
||||
<SiteHeader role={role} />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="@container/main flex flex-1 flex-col gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
93
components/layout/nav-documents.tsx
Normal file
93
components/layout/nav-documents.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
IconDots,
|
||||
IconFolder,
|
||||
IconShare3,
|
||||
IconTrash,
|
||||
type Icon,
|
||||
} from "@tabler/icons-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
name: string
|
||||
url: string
|
||||
icon: Icon
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="data-[state=open]:bg-accent rounded-sm"
|
||||
>
|
||||
<IconDots />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-24 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<IconFolder />
|
||||
<span>Open</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconShare3 />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<IconTrash />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<IconDots className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
41
components/layout/nav-main.tsx
Normal file
41
components/layout/nav-main.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { type Icon } from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import Link from "next/link";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton tooltip={item.title} asChild>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
43
components/layout/nav-secondary.tsx
Normal file
43
components/layout/nav-secondary.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type Icon } from "@tabler/icons-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon: Icon
|
||||
}[]
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
131
components/layout/nav-user.tsx
Normal file
131
components/layout/nav-user.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconDotsVertical,
|
||||
IconLogout,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import { authClient } from "@/lib/auth-session/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
isAdmin,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string | null;
|
||||
};
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authClient.signOut();
|
||||
toast.success("Signed out successfully");
|
||||
router.push("/sign-in");
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg ">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
<IconDotsVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{user.name?.[0] ?? "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<IconCreditCard className="mr-2" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{user?.role !== "admin" && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile" className="cursor-pointer">
|
||||
<IconCreditCard className="mr-2" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuGroup />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<IconLogout />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
117
components/layout/section-cards.tsx
Normal file
117
components/layout/section-cards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { IconTrendingUp, IconTrendingDown } from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type DashboardStats = {
|
||||
totalAppointments: number
|
||||
appointmentChange: number
|
||||
newPatients: number
|
||||
patientChange: number
|
||||
revenue: number
|
||||
revenueChange: number
|
||||
satisfactionRate: number
|
||||
satisfactionChange: number
|
||||
}
|
||||
|
||||
export function SectionCards({ stats }: { stats: DashboardStats }) {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Appointments</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.totalAppointments.toLocaleString()}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.appointmentChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.appointmentChange >= 0 ? '+' : ''}{stats.appointmentChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.appointmentChange >= 0 ? 'Bookings up this month' : 'Bookings down this month'}{' '}
|
||||
{stats.appointmentChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Appointments for the last 30 days
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>New Patients</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.newPatients.toLocaleString()}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.patientChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.patientChange >= 0 ? '+' : ''}{stats.patientChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.patientChange >= 0 ? 'Growing patient base' : 'Patient growth slowing'}{' '}
|
||||
{stats.patientChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
New registrations this month
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Revenue This Month</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
₱{stats.revenue.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.revenueChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.revenueChange >= 0 ? '+' : ''}{stats.revenueChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.revenueChange >= 0 ? 'Strong financial performance' : 'Revenue needs attention'}{' '}
|
||||
{stats.revenueChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">Total revenue collected</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Patient Satisfaction</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.satisfactionRate.toFixed(1)}%
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
{stats.satisfactionChange >= 0 ? <IconTrendingUp /> : <IconTrendingDown />}
|
||||
{stats.satisfactionChange >= 0 ? '+' : ''}{stats.satisfactionChange.toFixed(1)}%
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
{stats.satisfactionRate >= 95 ? 'Excellent patient feedback' : 'Good patient feedback'}{' '}
|
||||
{stats.satisfactionChange >= 0 ? <IconTrendingUp className="size-4" /> : <IconTrendingDown className="size-4" />}
|
||||
</div>
|
||||
<div className="text-muted-foreground">Based on completed appointments</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
components/layout/site-header.tsx
Normal file
37
components/layout/site-header.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { ModeToggle } from "../ui/mode-toggle"
|
||||
|
||||
type SiteHeaderProps = {
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export function SiteHeader({ role }: SiteHeaderProps = {}) {
|
||||
const getTitle = () => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'Dental U-Care Admin Dashboard'
|
||||
case 'dentist':
|
||||
return 'Dental U-Care Dentist Portal'
|
||||
case 'patient':
|
||||
return 'Dental U-Care Patient Portal'
|
||||
default:
|
||||
return 'Dental U-Care'
|
||||
}
|
||||
}
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">{getTitle()}</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
455
components/patient/appointments-list.tsx
Normal file
455
components/patient/appointments-list.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Calendar, Clock, User, DollarSign, Eye } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Appointment = {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
dentist: {
|
||||
name: string;
|
||||
image: string | null;
|
||||
};
|
||||
service: {
|
||||
name: string;
|
||||
price: number | string;
|
||||
};
|
||||
payment: {
|
||||
status: string;
|
||||
amount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type AppointmentsListProps = {
|
||||
appointments: Appointment[];
|
||||
};
|
||||
|
||||
export function AppointmentsList({ appointments }: AppointmentsListProps) {
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] =
|
||||
useState<Appointment | null>(null);
|
||||
|
||||
const upcomingAppointments = appointments.filter(
|
||||
(apt) => new Date(apt.date) >= new Date() && apt.status !== "cancelled"
|
||||
);
|
||||
const pastAppointments = appointments.filter(
|
||||
(apt) => new Date(apt.date) < new Date() || apt.status === "cancelled"
|
||||
);
|
||||
|
||||
const handleCancelAppointment = async (appointmentId: string) => {
|
||||
if (!confirm("Are you sure you want to cancel this appointment?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(appointmentId);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/appointments/${appointmentId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: "cancelled",
|
||||
cancelReason: "Cancelled by patient",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to cancel appointment");
|
||||
}
|
||||
|
||||
toast.success("Appointment cancelled successfully");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to cancel appointment");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getPaymentBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
paid: "default",
|
||||
pending: "secondary",
|
||||
failed: "destructive",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const formatPrice = (price: number | string): string => {
|
||||
if (typeof price === "string") {
|
||||
return price;
|
||||
}
|
||||
if (isNaN(price)) {
|
||||
return "Contact for pricing";
|
||||
}
|
||||
return `₱${price.toLocaleString()}`;
|
||||
};
|
||||
|
||||
const renderAppointmentCard = (appointment: Appointment) => (
|
||||
<Card key={appointment.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{appointment.service.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-1 mt-1">
|
||||
<User className="h-3 w-3" />
|
||||
Dr. {appointment.dentist.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge(appointment.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{new Date(appointment.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{appointment.timeSlot}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{formatPrice(appointment.service.price)}</span>
|
||||
</div>
|
||||
{appointment.payment && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Payment:</span>
|
||||
{getPaymentBadge(appointment.payment.status)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{appointment.notes && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Notes:</p>
|
||||
<p className="text-muted-foreground">{appointment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appointment.status === "pending" ||
|
||||
appointment.status === "confirmed" ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setSelectedAppointment(appointment)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toast.info("Reschedule feature coming soon")}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleCancelAppointment(appointment.id)}
|
||||
disabled={isLoading === appointment.id}
|
||||
>
|
||||
{isLoading === appointment.id ? "Cancelling..." : "Cancel"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setSelectedAppointment(appointment)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{appointment.status === "completed" &&
|
||||
appointment.payment?.status === "pending" && (
|
||||
<Button className="w-full" size="sm">
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="upcoming" className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="upcoming">
|
||||
Upcoming ({upcomingAppointments.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
Past ({pastAppointments.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="upcoming" className="space-y-4 mt-6">
|
||||
{upcomingAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No upcoming appointments
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() =>
|
||||
(window.location.href = "/patient/book-appointment")
|
||||
}
|
||||
>
|
||||
Book an Appointment
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
upcomingAppointments.map(renderAppointmentCard)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past" className="space-y-4 mt-6">
|
||||
{pastAppointments.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No past appointments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
pastAppointments.map(renderAppointmentCard)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Appointment Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedAppointment}
|
||||
onOpenChange={() => setSelectedAppointment(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-2xl">
|
||||
Appointment Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Booking ID: {selectedAppointment?.id}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{selectedAppointment &&
|
||||
getStatusBadge(selectedAppointment.status)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedAppointment && (
|
||||
<div className="space-y-6">
|
||||
{/* Service Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Service Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Service</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.service.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Price</p>
|
||||
<p className="font-medium">
|
||||
{formatPrice(selectedAppointment.service.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Appointment Details */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Appointment Schedule
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Date</p>
|
||||
<p className="font-medium">
|
||||
{new Date(selectedAppointment.date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Time</p>
|
||||
<p className="font-medium">
|
||||
{selectedAppointment.timeSlot}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentist Information */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Dentist Information
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your Dentist
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
Dr. {selectedAppointment.dentist.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
{selectedAppointment.payment && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Payment Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Payment Status
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{getPaymentBadge(selectedAppointment.payment.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Amount</p>
|
||||
<p className="font-medium">
|
||||
₱{selectedAppointment.payment.amount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{selectedAppointment.notes && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">
|
||||
Special Requests / Notes
|
||||
</h3>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">
|
||||
{selectedAppointment.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{selectedAppointment.status === "pending" ||
|
||||
selectedAppointment.status === "confirmed" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedAppointment(null);
|
||||
toast.info("Reschedule feature coming soon");
|
||||
}}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
const id = selectedAppointment.id;
|
||||
setSelectedAppointment(null);
|
||||
handleCancelAppointment(id);
|
||||
}}
|
||||
disabled={isLoading === selectedAppointment.id}
|
||||
>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
</>
|
||||
) : selectedAppointment.status === "completed" &&
|
||||
selectedAppointment.payment?.status === "pending" ? (
|
||||
<Button className="w-full">Pay Now</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
404
components/patient/appointments-table.tsx
Normal file
404
components/patient/appointments-table.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
type PatientAppointment = {
|
||||
id: string
|
||||
date: Date
|
||||
timeSlot: string
|
||||
status: string
|
||||
notes: string | null
|
||||
dentist: {
|
||||
name: string
|
||||
specialization: string | null
|
||||
}
|
||||
service: {
|
||||
name: string
|
||||
price: number
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
pending: "secondary",
|
||||
confirmed: "default",
|
||||
cancelled: "destructive",
|
||||
completed: "outline",
|
||||
rescheduled: "secondary",
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<PatientAppointment>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "serviceName",
|
||||
accessorFn: (row) => row.service.name,
|
||||
header: "Service",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.service.name}</p>
|
||||
<p className="text-xs text-muted-foreground">₱{row.original.service.price.toFixed(2)}</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "dentistName",
|
||||
accessorFn: (row) => row.dentist.name,
|
||||
header: "Dentist",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">Dr. {row.original.dentist.name}</p>
|
||||
{row.original.dentist.specialization && (
|
||||
<p className="text-xs text-muted-foreground">{row.original.dentist.specialization}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: "Date & Time",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p>{new Date(row.original.date).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.original.timeSlot}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => getStatusBadge(row.original.status),
|
||||
},
|
||||
{
|
||||
accessorKey: "notes",
|
||||
header: "Notes",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[200px] truncate">
|
||||
{row.original.notes || "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const canModify = row.original.status === "pending" || row.original.status === "confirmed"
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Details</DropdownMenuItem>
|
||||
{canModify && (
|
||||
<>
|
||||
<DropdownMenuItem>Reschedule</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">Cancel</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
type PatientAppointmentsTableProps = {
|
||||
appointments: PatientAppointment[]
|
||||
}
|
||||
|
||||
export function PatientAppointmentsTable({ appointments }: PatientAppointmentsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
data: appointments,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search appointments..."
|
||||
value={(table.getColumn("serviceName")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("serviceName")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No appointments found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1759
components/patient/booking-form.tsx
Normal file
1759
components/patient/booking-form.tsx
Normal file
File diff suppressed because it is too large
Load Diff
186
components/patient/payment-history.tsx
Normal file
186
components/patient/payment-history.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreditCard, Download } from "lucide-react";
|
||||
|
||||
type Payment = {
|
||||
id: string;
|
||||
amount: number;
|
||||
method: string;
|
||||
status: string;
|
||||
transactionId: string | null;
|
||||
paidAt: Date | null;
|
||||
createdAt: Date;
|
||||
appointment: {
|
||||
date: Date;
|
||||
timeSlot: string;
|
||||
service: {
|
||||
name: string;
|
||||
};
|
||||
dentist: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PaymentHistoryProps = {
|
||||
payments: Payment[];
|
||||
};
|
||||
|
||||
export function PaymentHistory({ payments }: PaymentHistoryProps) {
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
paid: "default",
|
||||
pending: "secondary",
|
||||
failed: "destructive",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"}>
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getMethodIcon = () => {
|
||||
return <CreditCard className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const totalPaid = payments
|
||||
.filter((p) => p.status === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const pendingAmount = payments
|
||||
.filter((p) => p.status === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Paid</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">₱{totalPaid.toFixed(2)}</div>
|
||||
<p className="text-xs text-muted-foreground">All time</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Pending Payments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₱{pendingAmount.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Outstanding balance</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Transactions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{payments.length}</div>
|
||||
<p className="text-xs text-muted-foreground">All payments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Payment List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction History</CardTitle>
|
||||
<CardDescription>All your payment transactions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{payments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
No payment history
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{payments.map((payment) => (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="mt-1">{getMethodIcon()}</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{payment.appointment.service.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dr. {payment.appointment.dentist.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(
|
||||
payment.appointment.date
|
||||
).toLocaleDateString()}{" "}
|
||||
at {payment.appointment.timeSlot}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{payment.paidAt
|
||||
? `Paid on ${new Date(payment.paidAt).toLocaleDateString()}`
|
||||
: `Created on ${new Date(payment.createdAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
{payment.transactionId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
• ID: {payment.transactionId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-2">
|
||||
<p className="font-bold text-lg">
|
||||
₱{payment.amount.toFixed(2)}
|
||||
</p>
|
||||
{getStatusBadge(payment.status)}
|
||||
{payment.status === "pending" && (
|
||||
<Button size="sm" className="w-full mt-2">
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
{payment.status === "paid" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Receipt
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
424
components/patient/payments-table.tsx
Normal file
424
components/patient/payments-table.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
IconDotsVertical,
|
||||
IconLayoutColumns,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
type PatientPayment = {
|
||||
id: string
|
||||
amount: number
|
||||
method: string
|
||||
status: string
|
||||
transactionId: string | null
|
||||
paidAt: Date | null
|
||||
createdAt: Date
|
||||
appointment: {
|
||||
service: {
|
||||
name: string
|
||||
}
|
||||
date: Date
|
||||
dentist: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getPaymentBadge = (status: string) => {
|
||||
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
paid: "default",
|
||||
pending: "secondary",
|
||||
failed: "destructive",
|
||||
refunded: "outline",
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || "default"} className="text-xs">
|
||||
{status.toUpperCase()}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const getMethodBadge = (method: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
card: "Card",
|
||||
e_wallet: "E-Wallet",
|
||||
bank_transfer: "Bank Transfer",
|
||||
cash: "Cash",
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{labels[method] || method}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: ColumnDef<PatientPayment>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "serviceName",
|
||||
accessorFn: (row) => row.appointment.service.name,
|
||||
header: "Service",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="font-medium">{row.original.appointment.service.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dr. {row.original.appointment.dentist.name}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: () => <div className="text-right">Amount</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right font-medium">
|
||||
₱{row.original.amount.toFixed(2)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: "Payment Method",
|
||||
cell: ({ row }) => getMethodBadge(row.original.method),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => getPaymentBadge(row.original.status),
|
||||
},
|
||||
{
|
||||
accessorKey: "transactionId",
|
||||
header: "Transaction ID",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[150px] truncate font-mono text-xs">
|
||||
{row.original.transactionId || "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "paidAt",
|
||||
header: "Payment Date",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
{row.original.paidAt ? (
|
||||
<>
|
||||
<p>{new Date(row.original.paidAt).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.paidAt).toLocaleTimeString()}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="data-[state=open]:bg-muted text-muted-foreground flex size-8"
|
||||
size="icon"
|
||||
>
|
||||
<IconDotsVertical />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem>View Receipt</DropdownMenuItem>
|
||||
<DropdownMenuItem>Download Invoice</DropdownMenuItem>
|
||||
{row.original.status === "pending" && (
|
||||
<DropdownMenuItem>Pay Now</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
type PatientPaymentsTableProps = {
|
||||
payments: PatientPayment[]
|
||||
}
|
||||
|
||||
export function PatientPaymentsTable({ payments }: PatientPaymentsTableProps) {
|
||||
const [rowSelection, setRowSelection] = React.useState({})
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [pagination, setPagination] = React.useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
data: payments,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<IconSearch className="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search payments..."
|
||||
value={(table.getColumn("serviceName")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("serviceName")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<IconLayoutColumns />
|
||||
<span className="hidden lg:inline">Columns</span>
|
||||
<IconChevronDown />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No payments found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-8 lg:w-fit">
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-fit items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2 lg:ml-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<IconChevronsLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="size-8"
|
||||
size="icon"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-8 lg:flex"
|
||||
size="icon"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<IconChevronsRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
components/patient/section-cards.tsx
Normal file
112
components/patient/section-cards.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { IconCalendar, IconClock, IconCreditCard, IconCircleCheck } from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type PatientStats = {
|
||||
upcomingAppointments: number
|
||||
completedAppointments: number
|
||||
totalSpent: number
|
||||
pendingPayments: number
|
||||
}
|
||||
|
||||
export function PatientSectionCards({ stats }: { stats: PatientStats }) {
|
||||
return (
|
||||
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Upcoming Appointments</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.upcomingAppointments}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconCalendar className="size-3" />
|
||||
Scheduled
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Your scheduled visits <IconClock className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Appointments waiting for you
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Completed Visits</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{stats.completedAppointments}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconCircleCheck className="size-3" />
|
||||
Done
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Total completed appointments <IconCircleCheck className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
Your dental care history
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Total Spent</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
₱{stats.totalSpent.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="outline">
|
||||
<IconCreditCard className="size-3" />
|
||||
Paid
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Total payments made <IconCreditCard className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Lifetime spending on dental care</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="@container/card">
|
||||
<CardHeader>
|
||||
<CardDescription>Pending Payments</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
₱{stats.pendingPayments.toLocaleString('en-PH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<Badge variant="secondary">
|
||||
<IconClock className="size-3" />
|
||||
Pending
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardFooter className="flex-col items-start gap-1.5 text-sm">
|
||||
<div className="line-clamp-1 flex gap-2 font-medium">
|
||||
Outstanding balance <IconCreditCard className="size-4" />
|
||||
</div>
|
||||
<div className="text-muted-foreground">Payments awaiting settlement</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
components/patient/services-display.tsx
Normal file
139
components/patient/services-display.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { Check, Calendar } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
type Service = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
duration: number
|
||||
price: number
|
||||
category: string
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
type ServicesDisplayProps = {
|
||||
services: Service[]
|
||||
onSelectService?: (serviceId: string) => void
|
||||
scrollToBooking?: boolean
|
||||
}
|
||||
|
||||
export function ServicesDisplay({ services, onSelectService, scrollToBooking = true }: ServicesDisplayProps) {
|
||||
const handleServiceSelect = (serviceId: string) => {
|
||||
if (onSelectService) {
|
||||
onSelectService(serviceId)
|
||||
}
|
||||
|
||||
if (scrollToBooking) {
|
||||
// Scroll to booking form
|
||||
setTimeout(() => {
|
||||
const bookingSection = document.getElementById('booking-form-section')
|
||||
if (bookingSection) {
|
||||
bookingSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
// Group services by category
|
||||
const servicesByCategory = services.reduce((acc, service) => {
|
||||
if (!acc[service.category]) {
|
||||
acc[service.category] = []
|
||||
}
|
||||
acc[service.category].push(service)
|
||||
return acc
|
||||
}, {} as Record<string, Service[]>)
|
||||
|
||||
const categories = Object.keys(servicesByCategory)
|
||||
const defaultCategory = categories[0] || ""
|
||||
|
||||
// Map category names to badges
|
||||
const categoryBadges: Record<string, string> = {
|
||||
"Preventive Care": "Essential",
|
||||
"Restorative": "Popular",
|
||||
"Cosmetic": "Premium",
|
||||
"Orthodontics": "Advanced",
|
||||
"Emergency": "Urgent",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue={defaultCategory} className="w-full">
|
||||
<TabsList className="grid w-full h-auto gap-2" style={{ gridTemplateColumns: `repeat(${Math.min(categories.length, 5)}, minmax(0, 1fr))` }}>
|
||||
{categories.map((category) => (
|
||||
<TabsTrigger
|
||||
key={category}
|
||||
value={category}
|
||||
className="text-sm sm:text-base"
|
||||
>
|
||||
{category}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{categories.map((category) => (
|
||||
<TabsContent
|
||||
key={category}
|
||||
value={category}
|
||||
className="mt-6 animate-in fade-in-50 slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl">{category}</CardTitle>
|
||||
<Badge className="uppercase">
|
||||
{categoryBadges[category] || "Service"}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Professional dental services with transparent pricing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{servicesByCategory[category].map((service, index) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="flex flex-col p-4 rounded-lg border hover:border-primary transition-all duration-300 hover:shadow-md animate-in fade-in-50 slide-in-from-left-4"
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<Check className="size-5 text-primary mt-1 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">{service.name}</h3>
|
||||
<p className="text-muted-foreground text-sm mb-2">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>⏱️ {service.duration} mins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-auto pt-3 border-t">
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
₱{service.price.toLocaleString()}
|
||||
</span>
|
||||
{onSelectService && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleServiceSelect(service.id)}
|
||||
>
|
||||
<Calendar className="size-4 mr-2" />
|
||||
Book Now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
components/provider/theme-provider.tsx
Normal file
11
components/provider/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
70
components/services/CosmeticDentistry.tsx
Normal file
70
components/services/CosmeticDentistry.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
import { Sparkles } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { serviceCategories } from "@/lib/types/services-data";
|
||||
|
||||
export function CosmeticDentistry() {
|
||||
const cosmeticServices = serviceCategories.find(cat => cat.id === "cosmetic")?.services || [];
|
||||
|
||||
return (
|
||||
<section className="w-full max-w-2xl mt-10 mb-8 mx-auto">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-center gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Cosmetic Dentistry
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Teeth whitening, veneers, and smile makeovers
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-6 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70 bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/30 dark:to-pink-950/30 mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-gradient-to-br from-purple-500 to-pink-500 text-white rounded-full p-3 shadow-lg">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Cosmetic Dentistry</h3>
|
||||
<div className="flex items-center gap-2 sm:ml-4">
|
||||
<Image
|
||||
src="/cervs.jpg"
|
||||
alt="Clyrelle Jade Cervantes"
|
||||
className="w-10 h-10 rounded-full object-cover border"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
<span className="font-semibold">Clyrelle Jade Cervantes</span>
|
||||
<span className="text-primary text-sm">Cosmetic Dentistry Specialist</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Transform your smile with our advanced cosmetic procedures. From teeth
|
||||
whitening to complete smile makeovers, we help you achieve the perfect
|
||||
smile.
|
||||
</p>
|
||||
<ul className="space-y-2 text-left mb-6">
|
||||
{cosmeticServices.map((service) => (
|
||||
<li key={service.id} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 inline-block" />
|
||||
{service.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Team Member */}
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mt-4">
|
||||
<div className="font-bold mb-2">Pricing</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{cosmeticServices.map((service) => (
|
||||
<li key={service.id} className="flex justify-between">
|
||||
<span>{service.name}</span>
|
||||
<span className="font-semibold text-primary">{service.price}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
components/services/EmergencyCare.tsx
Normal file
82
components/services/EmergencyCare.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function EmergencyCare() {
|
||||
return (
|
||||
<section className="w-full max-w-2xl mt-2 mb-8 mx-auto">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-center gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Emergency Dental Care
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Same-day treatment for tooth pain, injuries, and urgent issues
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-6 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70 bg-gradient-to-br from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-gradient-to-br from-rose-500 to-red-600 text-white rounded-full p-3 shadow-lg">
|
||||
<ShieldAlert className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Emergency Dental Care</h3>
|
||||
<div className="flex items-center gap-2 sm:ml-4">
|
||||
<Image
|
||||
src="/von.jpg"
|
||||
alt="Von Vryan Arguelles"
|
||||
className="w-10 h-10 rounded-full object-cover border"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
<span className="font-semibold">Von Vryan Arguelles</span>
|
||||
<span className="text-primary text-sm">Oral Surgeon</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Same-day emergency services for dental injuries, severe toothaches,
|
||||
broken teeth, and other urgent dental issues.
|
||||
</p>
|
||||
<ul className="space-y-2 text-left mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-rose-500 to-red-600 inline-block" />
|
||||
Tooth Pain Relief
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-rose-500 to-red-600 inline-block" />
|
||||
Broken Tooth Repair
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-rose-500 to-red-600 inline-block" />
|
||||
Same-Day Treatment
|
||||
</li>
|
||||
</ul>
|
||||
{/* Team Member */}
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mt-4">
|
||||
<div className="font-bold mb-2">Pricing</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li className="flex justify-between">
|
||||
<span>Tooth Pain Relief</span>
|
||||
<span className="font-semibold text-primary">
|
||||
₱1,500 – ₱5,000
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Broken Tooth Repair</span>
|
||||
<span className="font-semibold text-primary">
|
||||
₱3,000 – ₱10,000+
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Same-Day Treatment</span>
|
||||
<span className="font-semibold text-primary">Varies</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
73
components/services/Orthodontics.tsx
Normal file
73
components/services/Orthodontics.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
import { Brackets } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { serviceCategories } from "@/lib/types/services-data";
|
||||
|
||||
export function Orthodontics() {
|
||||
const orthodonticServices = serviceCategories.find(cat => cat.id === "cosmetic")?.services.filter(service =>
|
||||
service.name.toLowerCase().includes('braces') || service.name.toLowerCase().includes('veneers')
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<section className="w-full max-w-2xl mt-5 mb-8 mx-auto">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-center gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Orthodontics
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Braces and clear aligners for children and adults
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-6 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70 bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/30 dark:to-emerald-950/30 mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-gradient-to-br from-green-500 to-emerald-500 text-white rounded-full p-3 shadow-lg">
|
||||
<Brackets className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Orthodontics</h3>
|
||||
<div className="flex items-center gap-2 sm:ml-4">
|
||||
<Image
|
||||
src="/kath.jpg"
|
||||
alt="Kath Estrada"
|
||||
className="w-10 h-10 rounded-full object-cover border"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
<span className="font-semibold">Kath Estrada</span>
|
||||
<span className="text-primary text-sm">
|
||||
Chief Dentist & Orthodontist
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Straighten your teeth and correct bite issues with traditional braces
|
||||
or modern clear aligners for both children and adults.
|
||||
</p>
|
||||
<ul className="space-y-2 text-left mb-6">
|
||||
{orthodonticServices.map((service) => (
|
||||
<li key={service.id} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-green-500 to-emerald-500 inline-block" />
|
||||
{service.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Team Member */}
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="mt-4">
|
||||
<div className="font-bold mb-2">Pricing</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{orthodonticServices.map((service) => (
|
||||
<li key={service.id} className="flex justify-between">
|
||||
<span>{service.name}</span>
|
||||
<span className="font-semibold text-primary">{service.price}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
components/services/PediatricDentistry.tsx
Normal file
82
components/services/PediatricDentistry.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
import { Baby } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function PediatricDentistry() {
|
||||
return (
|
||||
<section className="w-full max-w-2xl mt-5 mb-8 mx-auto">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-center gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Pediatric Dentistry
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Gentle, kid-friendly dental care for your little ones
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-6 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70 bg-gradient-to-br from-yellow-50 to-amber-50 dark:from-yellow-950/30 dark:to-amber-950/30 mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-gradient-to-br from-yellow-500 to-amber-500 text-white rounded-full p-3 shadow-lg">
|
||||
<Baby className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Pediatric Dentistry</h3>
|
||||
<div className="flex items-center gap-2 sm:ml-4">
|
||||
<Image
|
||||
src="/kath.jpg"
|
||||
alt="Kath Estrada"
|
||||
className="w-10 h-10 rounded-full object-cover border"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
<span className="font-semibold">Kath Estrada</span>
|
||||
<span className="text-primary text-sm">
|
||||
Chief Dentist & Orthodontist
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Gentle, kid-friendly dental care designed to make children feel
|
||||
comfortable and establish healthy oral hygiene habits from an early
|
||||
age.
|
||||
</p>
|
||||
<ul className="space-y-2 text-left mb-6">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-yellow-500 to-amber-500 inline-block" />
|
||||
Children`s Checkups
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-yellow-500 to-amber-500 inline-block" />
|
||||
Fluoride Treatment
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-yellow-500 to-amber-500 inline-block" />
|
||||
Sealants
|
||||
</li>
|
||||
</ul>
|
||||
{/* Team Member */}
|
||||
{/* Pricing */}
|
||||
<div className="mt-4">
|
||||
<div className="font-bold mb-2">Pricing</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
<li className="flex justify-between">
|
||||
<span>Children`s Checkups</span>
|
||||
<span className="font-semibold text-primary">₱500 – ₱1,500</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Fluoride Treatment</span>
|
||||
<span className="font-semibold text-primary">₱700 – ₱1,500</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span>Sealants</span>
|
||||
<span className="font-semibold text-primary">
|
||||
₱1,000 – ₱2,500
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
69
components/services/PreventiveCare.tsx
Normal file
69
components/services/PreventiveCare.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { serviceCategories } from "@/lib/types/services-data";
|
||||
|
||||
export function PreventiveCare() {
|
||||
const preventiveServices = serviceCategories.find(cat => cat.id === "basic")?.services || [];
|
||||
|
||||
return (
|
||||
<section className="w-full max-w-2xl mt-5 mb-8 mx-auto">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-center gap-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Preventive Care
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mx-auto max-w-2xl text-lg tracking-tight md:text-xl">
|
||||
Cleanings, exams, and routine check-ups to keep smiles healthy
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border p-6 shadow-lg dark:shadow-xl dark:shadow-gray-900/50 transition-shadow duration-300 hover:shadow-2xl dark:hover:shadow-2xl dark:hover:shadow-gray-900/70 bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/30 dark:to-cyan-950/30 mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="bg-gradient-to-br from-blue-500 to-cyan-500 text-white rounded-full p-3 shadow-lg">
|
||||
<Stethoscope className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">General Dentistry</h3>
|
||||
<div className="flex items-center gap-4 sm:ml-15">
|
||||
<Image
|
||||
src="/dexter.jpg"
|
||||
alt="Dexter Cabanag"
|
||||
className="w-10 h-10 rounded-full object-cover border"
|
||||
width={40}
|
||||
height={40}
|
||||
priority
|
||||
/>
|
||||
<span className="font-semibold">Dexter Cabanag</span>
|
||||
<span className="text-primary text-sm">Periodontist</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed mb-4">
|
||||
Comprehensive oral health care including routine checkups,
|
||||
professional cleanings, and preventive treatments to maintain your
|
||||
dental health.
|
||||
</p>
|
||||
<ul className="space-y-2 text-left mb-6">
|
||||
{preventiveServices.slice(0, 3).map((service) => (
|
||||
<li key={service.id} className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 inline-block" />
|
||||
{service.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Team Member */}
|
||||
{/* Pricing */}
|
||||
<div className="mt-4">
|
||||
<div className="font-bold mb-2">Pricing</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{preventiveServices.slice(0, 3).map((service) => (
|
||||
<li key={service.id} className="flex justify-between">
|
||||
<span>{service.name}</span>
|
||||
<span className="font-semibold text-primary">{service.price}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
16
components/services/types.ts
Normal file
16
components/services/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration: number;
|
||||
price: string;
|
||||
category: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface Dentist {
|
||||
id: string;
|
||||
name: string;
|
||||
specialization: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
53
components/ui/avatar.tsx
Normal file
53
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
components/ui/breadcrumb.tsx
Normal file
109
components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
60
components/ui/button.tsx
Normal file
60
components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
213
components/ui/calendar.tsx
Normal file
213
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
357
components/ui/chart.tsx
Normal file
357
components/ui/chart.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
143
components/ui/dialog.tsx
Normal file
143
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
135
components/ui/drawer.tsx
Normal file
135
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
244
components/ui/field.tsx
Normal file
244
components/ui/field.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (errors?.length == 1) {
|
||||
return errors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
170
components/ui/input-group.tsx
Normal file
170
components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-border bg-background dark:bg-input/30 dark:border-input relative flex w-full items-center rounded-md border-2 shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent text-foreground placeholder:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-background dark:bg-input/30 border-border dark:border-input h-9 w-full min-w-0 rounded-md border-2 px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-foreground",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
40
components/ui/mode-toggle.tsx
Normal file
40
components/ui/mode-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
168
components/ui/navigation-menu.tsx
Normal file
168
components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
45
components/ui/radio-group.tsx
Normal file
45
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
187
components/ui/select.tsx
Normal file
187
components/ui/select.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-border dark:border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive bg-background dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border-2 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
62
components/ui/shadcn-io/aurora-background/index.tsx
Normal file
62
components/ui/shadcn-io/aurora-background/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
interface AuroraBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
showRadialGradient?: boolean;
|
||||
}
|
||||
|
||||
export const AuroraBackground = ({
|
||||
className,
|
||||
children,
|
||||
showRadialGradient = true,
|
||||
...props
|
||||
}: AuroraBackgroundProps) => {
|
||||
return (
|
||||
<main>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={
|
||||
{
|
||||
"--aurora":
|
||||
"repeating-linear-gradient(100deg,#3b82f6_10%,#a5b4fc_15%,#93c5fd_20%,#ddd6fe_25%,#60a5fa_30%)",
|
||||
"--dark-gradient":
|
||||
"repeating-linear-gradient(100deg,#000_0%,#000_7%,transparent_10%,transparent_12%,#000_16%)",
|
||||
"--white-gradient":
|
||||
"repeating-linear-gradient(100deg,#fff_0%,#fff_7%,transparent_10%,transparent_12%,#fff_16%)",
|
||||
|
||||
"--blue-300": "#93c5fd",
|
||||
"--blue-400": "#60a5fa",
|
||||
"--blue-500": "#3b82f6",
|
||||
"--indigo-300": "#a5b4fc",
|
||||
"--violet-200": "#ddd6fe",
|
||||
"--black": "#000",
|
||||
"--white": "#fff",
|
||||
"--transparent": "transparent",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
// I'm sorry but this is what peak developer performance looks like // trigger warning
|
||||
className={cn(
|
||||
`after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
|
||||
|
||||
showRadialGradient &&
|
||||
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`,
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
139
components/ui/sheet.tsx
Normal file
139
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
80
components/ui/shimmering-text.tsx
Normal file
80
components/ui/shimmering-text.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type HTMLMotionProps, motion, type Transition } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ShimmeringTextProps = {
|
||||
text: string;
|
||||
duration?: number;
|
||||
transition?: Transition;
|
||||
wave?: boolean;
|
||||
color?: string;
|
||||
shimmeringColor?: string;
|
||||
} & Omit<HTMLMotionProps<"span">, "children">;
|
||||
|
||||
function ShimmeringText({
|
||||
text,
|
||||
duration = 1,
|
||||
transition,
|
||||
wave = false,
|
||||
className,
|
||||
color = "var(--color-neutral-500)",
|
||||
shimmeringColor = "var(--color-neutral-300)",
|
||||
...props
|
||||
}: ShimmeringTextProps) {
|
||||
return (
|
||||
<motion.span
|
||||
className={cn("relative inline-block [perspective:500px]", className)}
|
||||
style={
|
||||
{
|
||||
"--shimmering-color": shimmeringColor,
|
||||
"--color": color,
|
||||
color: "var(--color)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{text?.split("")?.map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="inline-block whitespace-pre [transform-style:preserve-3d]"
|
||||
initial={{
|
||||
...(wave
|
||||
? {
|
||||
scale: 1,
|
||||
rotateY: 0,
|
||||
}
|
||||
: {}),
|
||||
color: "var(--color)",
|
||||
}}
|
||||
animate={{
|
||||
...(wave
|
||||
? {
|
||||
x: [0, 5, 0],
|
||||
y: [0, -5, 0],
|
||||
scale: [1, 1.1, 1],
|
||||
rotateY: [0, 15, 0],
|
||||
}
|
||||
: {}),
|
||||
color: ["var(--color)", "var(--shimmering-color)", "var(--color)"],
|
||||
}}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
repeatDelay: text.length * 0.05,
|
||||
delay: (i * duration) / text.length,
|
||||
ease: "easeInOut",
|
||||
...transition,
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { ShimmeringText, type ShimmeringTextProps };
|
||||
726
components/ui/sidebar.tsx
Normal file
726
components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
40
components/ui/sonner.tsx
Normal file
40
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
261
components/ui/spinner.tsx
Normal file
261
components/ui/spinner.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
LoaderCircleIcon,
|
||||
LoaderIcon,
|
||||
LoaderPinwheelIcon,
|
||||
type LucideProps,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
type SpinnerVariantProps = Omit<SpinnerProps, "variant">;
|
||||
const Default = ({ className, ...props }: SpinnerVariantProps) => (
|
||||
<LoaderIcon className={cn("animate-spin", className)} {...props} />
|
||||
);
|
||||
const Circle = ({ className, ...props }: SpinnerVariantProps) => (
|
||||
<LoaderCircleIcon className={cn("animate-spin", className)} {...props} />
|
||||
);
|
||||
const Pinwheel = ({ className, ...props }: SpinnerVariantProps) => (
|
||||
<LoaderPinwheelIcon className={cn("animate-spin", className)} {...props} />
|
||||
);
|
||||
const CircleFilled = ({
|
||||
className,
|
||||
size = 24,
|
||||
...props
|
||||
}: SpinnerVariantProps) => (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<div className="absolute inset-0 rotate-180">
|
||||
<LoaderCircleIcon
|
||||
className={cn("animate-spin", className, "text-foreground opacity-20")}
|
||||
size={size}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<LoaderCircleIcon
|
||||
className={cn("relative animate-spin", className)}
|
||||
size={size}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const Ellipsis = ({ size = 24, ...props }: SpinnerVariantProps) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
<circle cx="4" cy="12" fill="currentColor" r="2">
|
||||
<animate
|
||||
attributeName="cy"
|
||||
begin="0;ellipsis3.end+0.25s"
|
||||
calcMode="spline"
|
||||
dur="0.6s"
|
||||
id="ellipsis1"
|
||||
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||
values="12;6;12"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="12" cy="12" fill="currentColor" r="2">
|
||||
<animate
|
||||
attributeName="cy"
|
||||
begin="ellipsis1.begin+0.1s"
|
||||
calcMode="spline"
|
||||
dur="0.6s"
|
||||
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||
values="12;6;12"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="20" cy="12" fill="currentColor" r="2">
|
||||
<animate
|
||||
attributeName="cy"
|
||||
begin="ellipsis1.begin+0.2s"
|
||||
calcMode="spline"
|
||||
dur="0.6s"
|
||||
id="ellipsis3"
|
||||
keySplines=".33,.66,.66,1;.33,0,.66,.33"
|
||||
values="12;6;12"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
const Ring = ({ size = 24, ...props }: SpinnerVariantProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 44 44"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
<g fill="none" fillRule="evenodd" strokeWidth="2">
|
||||
<circle cx="22" cy="22" r="1">
|
||||
<animate
|
||||
attributeName="r"
|
||||
begin="0s"
|
||||
calcMode="spline"
|
||||
dur="1.8s"
|
||||
keySplines="0.165, 0.84, 0.44, 1"
|
||||
keyTimes="0; 1"
|
||||
repeatCount="indefinite"
|
||||
values="1; 20"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-opacity"
|
||||
begin="0s"
|
||||
calcMode="spline"
|
||||
dur="1.8s"
|
||||
keySplines="0.3, 0.61, 0.355, 1"
|
||||
keyTimes="0; 1"
|
||||
repeatCount="indefinite"
|
||||
values="1; 0"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="22" cy="22" r="1">
|
||||
<animate
|
||||
attributeName="r"
|
||||
begin="-0.9s"
|
||||
calcMode="spline"
|
||||
dur="1.8s"
|
||||
keySplines="0.165, 0.84, 0.44, 1"
|
||||
keyTimes="0; 1"
|
||||
repeatCount="indefinite"
|
||||
values="1; 20"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-opacity"
|
||||
begin="-0.9s"
|
||||
calcMode="spline"
|
||||
dur="1.8s"
|
||||
keySplines="0.3, 0.61, 0.355, 1"
|
||||
keyTimes="0; 1"
|
||||
repeatCount="indefinite"
|
||||
values="1; 0"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
const Bars = ({ size = 24, ...props }: SpinnerVariantProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
<style>{`
|
||||
.spinner-bar {
|
||||
animation: spinner-bars-animation .8s linear infinite;
|
||||
animation-delay: -.8s;
|
||||
}
|
||||
.spinner-bars-2 {
|
||||
animation-delay: -.65s;
|
||||
}
|
||||
.spinner-bars-3 {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
@keyframes spinner-bars-animation {
|
||||
0% {
|
||||
y: 1px;
|
||||
height: 22px;
|
||||
}
|
||||
93.75% {
|
||||
y: 5px;
|
||||
height: 14px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<rect
|
||||
className="spinner-bar"
|
||||
fill="currentColor"
|
||||
height="22"
|
||||
width="6"
|
||||
x="1"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
className="spinner-bar spinner-bars-2"
|
||||
fill="currentColor"
|
||||
height="22"
|
||||
width="6"
|
||||
x="9"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
className="spinner-bar spinner-bars-3"
|
||||
fill="currentColor"
|
||||
height="22"
|
||||
width="6"
|
||||
x="17"
|
||||
y="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
const Infinite = ({ size = 24, ...props }: SpinnerVariantProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
preserveAspectRatio="xMidYMid"
|
||||
viewBox="0 0 100 100"
|
||||
width={size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>Loading...</title>
|
||||
<path
|
||||
d="M24.3 30C11.4 30 5 43.3 5 50s6.4 20 19.3 20c19.3 0 32.1-40 51.4-40 C88.6 30 95 43.3 95 50s-6.4 20-19.3 20C56.4 70 43.6 30 24.3 30z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeDasharray="205.271142578125 51.317785644531256"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="10"
|
||||
style={{
|
||||
transform: "scale(0.8)",
|
||||
transformOrigin: "50px 50px",
|
||||
}}
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
dur="2s"
|
||||
keyTimes="0;1"
|
||||
repeatCount="indefinite"
|
||||
values="0;256.58892822265625"
|
||||
/>
|
||||
</path>
|
||||
</svg>
|
||||
);
|
||||
export type SpinnerProps = LucideProps & {
|
||||
variant?:
|
||||
| "default"
|
||||
| "circle"
|
||||
| "pinwheel"
|
||||
| "circle-filled"
|
||||
| "ellipsis"
|
||||
| "ring"
|
||||
| "bars"
|
||||
| "infinite";
|
||||
};
|
||||
export const Spinner = ({ variant, ...props }: SpinnerProps) => {
|
||||
switch (variant) {
|
||||
case "circle":
|
||||
return <Circle {...props} />;
|
||||
case "pinwheel":
|
||||
return <Pinwheel {...props} />;
|
||||
case "circle-filled":
|
||||
return <CircleFilled {...props} />;
|
||||
case "ellipsis":
|
||||
return <Ellipsis {...props} />;
|
||||
case "ring":
|
||||
return <Ring {...props} />;
|
||||
case "bars":
|
||||
return <Bars {...props} />;
|
||||
case "infinite":
|
||||
return <Infinite {...props} />;
|
||||
default:
|
||||
return <Default {...props} />;
|
||||
}
|
||||
};
|
||||
31
components/ui/switch.tsx
Normal file
31
components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
116
components/ui/table.tsx
Normal file
116
components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
66
components/ui/tabs.tsx
Normal file
66
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-border dark:border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive bg-background dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border-2 px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
73
components/ui/toggle-group.tsx
Normal file
73
components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
47
components/ui/toggle.tsx
Normal file
47
components/ui/toggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
61
components/ui/tooltip.tsx
Normal file
61
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
198
components/ui/typing-text.tsx
Normal file
198
components/ui/typing-text.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { ElementType, useEffect, useRef, useState, createElement, useMemo, useCallback } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
|
||||
interface TypingTextProps {
|
||||
className?: string;
|
||||
showCursor?: boolean;
|
||||
hideCursorWhileTyping?: boolean;
|
||||
cursorCharacter?: string | React.ReactNode;
|
||||
cursorBlinkDuration?: number;
|
||||
cursorClassName?: string;
|
||||
text: string | string[];
|
||||
as?: ElementType;
|
||||
typingSpeed?: number;
|
||||
initialDelay?: number;
|
||||
pauseDuration?: number;
|
||||
deletingSpeed?: number;
|
||||
loop?: boolean;
|
||||
textColors?: string[];
|
||||
variableSpeed?: { min: number; max: number };
|
||||
onSentenceComplete?: (sentence: string, index: number) => void;
|
||||
startOnVisible?: boolean;
|
||||
reverseMode?: boolean;
|
||||
}
|
||||
|
||||
const TypingText = ({
|
||||
text,
|
||||
as: Component = 'div',
|
||||
typingSpeed = 50,
|
||||
initialDelay = 0,
|
||||
pauseDuration = 2000,
|
||||
deletingSpeed = 30,
|
||||
loop = true,
|
||||
className = '',
|
||||
showCursor = true,
|
||||
hideCursorWhileTyping = false,
|
||||
cursorCharacter = '|',
|
||||
cursorClassName = '',
|
||||
cursorBlinkDuration = 0.5,
|
||||
textColors = [],
|
||||
variableSpeed,
|
||||
onSentenceComplete,
|
||||
startOnVisible = false,
|
||||
reverseMode = false,
|
||||
...props
|
||||
}: TypingTextProps & React.HTMLAttributes<HTMLElement>) => {
|
||||
const [displayedText, setDisplayedText] = useState('');
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [currentTextIndex, setCurrentTextIndex] = useState(0);
|
||||
const [isVisible, setIsVisible] = useState(!startOnVisible);
|
||||
const cursorRef = useRef<HTMLSpanElement>(null);
|
||||
const containerRef = useRef<HTMLElement>(null);
|
||||
|
||||
const textArray = useMemo(() => (Array.isArray(text) ? text : [text]), [text]);
|
||||
|
||||
const getRandomSpeed = useCallback(() => {
|
||||
if (!variableSpeed) return typingSpeed;
|
||||
const { min, max } = variableSpeed;
|
||||
return Math.random() * (max - min) + min;
|
||||
}, [variableSpeed, typingSpeed]);
|
||||
|
||||
const getCurrentTextColor = () => {
|
||||
if (textColors.length === 0) return 'currentColor';
|
||||
return textColors[currentTextIndex % textColors.length];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!startOnVisible || !containerRef.current) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [startOnVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCursor && cursorRef.current) {
|
||||
gsap.set(cursorRef.current, { opacity: 1 });
|
||||
gsap.to(cursorRef.current, {
|
||||
opacity: 0,
|
||||
duration: cursorBlinkDuration,
|
||||
repeat: -1,
|
||||
yoyo: true,
|
||||
ease: 'power2.inOut'
|
||||
});
|
||||
}
|
||||
}, [showCursor, cursorBlinkDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
const currentText = textArray[currentTextIndex];
|
||||
const processedText = reverseMode ? currentText.split('').reverse().join('') : currentText;
|
||||
|
||||
const executeTypingAnimation = () => {
|
||||
if (isDeleting) {
|
||||
if (displayedText === '') {
|
||||
setIsDeleting(false);
|
||||
if (currentTextIndex === textArray.length - 1 && !loop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSentenceComplete) {
|
||||
onSentenceComplete(textArray[currentTextIndex], currentTextIndex);
|
||||
}
|
||||
|
||||
setCurrentTextIndex(prev => (prev + 1) % textArray.length);
|
||||
setCurrentCharIndex(0);
|
||||
timeout = setTimeout(() => {}, pauseDuration);
|
||||
} else {
|
||||
timeout = setTimeout(() => {
|
||||
setDisplayedText(prev => prev.slice(0, -1));
|
||||
}, deletingSpeed);
|
||||
}
|
||||
} else {
|
||||
if (currentCharIndex < processedText.length) {
|
||||
timeout = setTimeout(
|
||||
() => {
|
||||
setDisplayedText(prev => prev + processedText[currentCharIndex]);
|
||||
setCurrentCharIndex(prev => prev + 1);
|
||||
},
|
||||
variableSpeed ? getRandomSpeed() : typingSpeed
|
||||
);
|
||||
} else if (textArray.length > 1) {
|
||||
timeout = setTimeout(() => {
|
||||
setIsDeleting(true);
|
||||
}, pauseDuration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentCharIndex === 0 && !isDeleting && displayedText === '') {
|
||||
timeout = setTimeout(executeTypingAnimation, initialDelay);
|
||||
} else {
|
||||
executeTypingAnimation();
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [
|
||||
currentCharIndex,
|
||||
displayedText,
|
||||
isDeleting,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseDuration,
|
||||
textArray,
|
||||
currentTextIndex,
|
||||
loop,
|
||||
initialDelay,
|
||||
isVisible,
|
||||
reverseMode,
|
||||
variableSpeed,
|
||||
onSentenceComplete,
|
||||
getRandomSpeed
|
||||
]);
|
||||
|
||||
const shouldHideCursor =
|
||||
hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting);
|
||||
|
||||
return createElement(
|
||||
Component,
|
||||
{
|
||||
ref: containerRef,
|
||||
className: `inline-block whitespace-pre-wrap tracking-tight ${className}`,
|
||||
...props
|
||||
},
|
||||
<span className="inline" style={{ color: getCurrentTextColor() }}>
|
||||
{displayedText}
|
||||
</span>,
|
||||
showCursor && (
|
||||
<span
|
||||
ref={cursorRef}
|
||||
className={`inline-block opacity-100 ${shouldHideCursor ? 'hidden' : ''} ${
|
||||
cursorCharacter === '|'
|
||||
? `h-[1em] w-[2px] translate-y-[0.1em] bg-foreground ${cursorClassName}`
|
||||
: `ml-1 ${cursorClassName}`
|
||||
}`}
|
||||
>
|
||||
{cursorCharacter === '|' ? '' : cursorCharacter}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingText;
|
||||
656
components/user/settings-content.tsx
Normal file
656
components/user/settings-content.tsx
Normal file
@@ -0,0 +1,656 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
IconBell,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconShield,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
updateUserProfile,
|
||||
changePassword,
|
||||
getUserSettings,
|
||||
updateUserSettings,
|
||||
deleteUserAccount,
|
||||
exportUserData,
|
||||
} from "@/lib/actions/settings-actions";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
image?: string | null;
|
||||
dateOfBirth?: Date | null;
|
||||
address?: string | null;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type UserSettingsContentProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export function UserSettingsContent({ user }: UserSettingsContentProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
// Profile State
|
||||
const [name, setName] = React.useState(user.name);
|
||||
const [email, setEmail] = React.useState(user.email);
|
||||
const [phone, setPhone] = React.useState(user.phone || "");
|
||||
const [address, setAddress] = React.useState(user.address || "");
|
||||
const [dateOfBirth, setDateOfBirth] = React.useState(
|
||||
user.dateOfBirth
|
||||
? new Date(user.dateOfBirth).toISOString().split("T")[0]
|
||||
: ""
|
||||
);
|
||||
|
||||
// Password State
|
||||
const [currentPassword, setCurrentPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
|
||||
// Notification Settings
|
||||
const [emailNotifications, setEmailNotifications] = React.useState(true);
|
||||
const [smsNotifications, setSmsNotifications] = React.useState(true);
|
||||
const [appointmentReminders, setAppointmentReminders] = React.useState(true);
|
||||
const [promotionalEmails, setPromotionalEmails] = React.useState(false);
|
||||
const [reminderTiming, setReminderTiming] = React.useState("24");
|
||||
|
||||
// Privacy Settings
|
||||
const [profileVisibility, setProfileVisibility] = React.useState("private");
|
||||
const [shareData, setShareData] = React.useState(false);
|
||||
const [twoFactorAuth, setTwoFactorAuth] = React.useState(false);
|
||||
|
||||
// Load settings on mount
|
||||
React.useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const settings = await getUserSettings(user.id);
|
||||
if (settings) {
|
||||
setEmailNotifications(settings.emailNotifications);
|
||||
setSmsNotifications(settings.smsNotifications);
|
||||
setAppointmentReminders(settings.appointmentReminders);
|
||||
setPromotionalEmails(settings.promotionalEmails);
|
||||
setReminderTiming(settings.reminderTiming);
|
||||
setProfileVisibility(settings.profileVisibility);
|
||||
setShareData(settings.shareData);
|
||||
setTwoFactorAuth(settings.twoFactorAuth);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, [user.id]);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateUserProfile({
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
address,
|
||||
dateOfBirth,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to update profile");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to change password");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotifications = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateUserSettings({
|
||||
emailNotifications,
|
||||
smsNotifications,
|
||||
appointmentReminders,
|
||||
promotionalEmails,
|
||||
reminderTiming,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save notification settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePrivacy = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateUserSettings({
|
||||
profileVisibility,
|
||||
shareData,
|
||||
twoFactorAuth,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to save privacy settings");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to delete your account? This action cannot be undone."
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteUserAccount();
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Redirect to logout or home
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to delete account");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await exportUserData();
|
||||
if (result.success && result.data) {
|
||||
// Create a blob and download
|
||||
const blob = new Blob([result.data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `user-data-${user.id}-${new Date().toISOString()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message || "Failed to export data");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to export data");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4">
|
||||
<TabsTrigger value="profile" className="gap-2">
|
||||
<IconUser className="size-4" />
|
||||
<span className="hidden sm:inline">Profile</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="gap-2">
|
||||
<IconLock className="size-4" />
|
||||
<span className="hidden sm:inline">Security</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<IconBell className="size-4" />
|
||||
<span className="hidden sm:inline">Notifications</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="privacy" className="gap-2">
|
||||
<IconShield className="size-4" />
|
||||
<span className="hidden sm:inline">Privacy</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Profile Settings */}
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile Information</CardTitle>
|
||||
<CardDescription>
|
||||
Update your personal information and profile picture
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-20">
|
||||
<AvatarImage src={user.image || undefined} alt={user.name} />
|
||||
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<IconUpload className="size-4" />
|
||||
Upload Photo
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JPG, PNG or GIF. Max size 2MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone Number</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dob">Date of Birth</Label>
|
||||
<Input
|
||||
id="dob"
|
||||
type="date"
|
||||
value={dateOfBirth}
|
||||
onChange={(e) => setDateOfBirth(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Textarea
|
||||
id="address"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="123 Main Street, City, State, ZIP"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveProfile} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security Settings */}
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<p className="text-sm">
|
||||
<strong>Password requirements:</strong>
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
<li>• At least 8 characters long</li>
|
||||
<li>• Include uppercase and lowercase letters</li>
|
||||
<li>• Include at least one number</li>
|
||||
<li>• Include at least one special character</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleChangePassword} disabled={isLoading}>
|
||||
{isLoading ? "Changing..." : "Change Password"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Two-Factor Authentication</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Enable 2FA</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Require a verification code in addition to your password
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorAuth}
|
||||
onCheckedChange={setTwoFactorAuth}
|
||||
/>
|
||||
</div>
|
||||
{twoFactorAuth && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">Setup 2FA</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Scan the QR code with your authenticator app or enter the
|
||||
code manually
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="mt-3">
|
||||
Configure 2FA
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Notifications Settings */}
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how you want to receive notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Email Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive updates via email
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onCheckedChange={setEmailNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>SMS Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive updates via text message
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={smsNotifications}
|
||||
onCheckedChange={setSmsNotifications}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Appointment Reminders</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get reminded before your appointments
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={appointmentReminders}
|
||||
onCheckedChange={setAppointmentReminders}
|
||||
/>
|
||||
</div>
|
||||
{appointmentReminders && (
|
||||
<div className="ml-6 space-y-2">
|
||||
<Label htmlFor="reminder-timing">Reminder timing</Label>
|
||||
<Select
|
||||
value={reminderTiming}
|
||||
onValueChange={setReminderTiming}
|
||||
>
|
||||
<SelectTrigger id="reminder-timing">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2 hours before</SelectItem>
|
||||
<SelectItem value="4">4 hours before</SelectItem>
|
||||
<SelectItem value="12">12 hours before</SelectItem>
|
||||
<SelectItem value="24">24 hours before</SelectItem>
|
||||
<SelectItem value="48">48 hours before</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Promotional Emails</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive news, offers, and updates
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={promotionalEmails}
|
||||
onCheckedChange={setPromotionalEmails}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveNotifications} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Privacy Settings */}
|
||||
<TabsContent value="privacy" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Privacy Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Control your privacy and data sharing preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-visibility">Profile Visibility</Label>
|
||||
<Select
|
||||
value={profileVisibility}
|
||||
onValueChange={setProfileVisibility}
|
||||
>
|
||||
<SelectTrigger id="profile-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">Public</SelectItem>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="contacts">Contacts only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control who can see your profile information
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Share Data for Improvements</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Help us improve our services by sharing anonymous usage data
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={shareData} onCheckedChange={setShareData} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Data Management</Label>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={handleExportData}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconKey className="mr-2 size-4" />
|
||||
Download Your Data
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconShield className="mr-2 size-4" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSavePrivacy} disabled={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user