Dental Care

This commit is contained in:
Iliyan Angelov
2025-11-16 14:29:51 +02:00
commit 39077550ef
194 changed files with 43197 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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>
);
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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"
/>
)
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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;

View 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;

View 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;

View 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;

View 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 };

View 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 };

View 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 };

View 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 };

View 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 />Were excited to help you on your journey toward better oral health. Heres 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, youll 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>Well 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 youre 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>
)
}

View 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 };

View 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} />;
}

View 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 };

View 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 };

View 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>
);
}

View 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
View 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 };

View 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 dentists 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 materialsincluding text, logos, and imagesis 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>
);
}

View 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>
</>
);
}

View 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
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
</>
);
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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
View 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
View 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
View 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 }

View 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
View 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
View 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
View 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
View 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,
}

View 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 }

View 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
View 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
View 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,
}

View 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
View 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,
}

View 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
View 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
View 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 }

View 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>
);
}

View 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,
}

View 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
View 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,
};

View 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 }

View 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
View 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,
}

View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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 }

View 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 };

View 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
View 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
View 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 }

View 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;

View 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>
);
}