184 lines
7.0 KiB
TypeScript
184 lines
7.0 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { CalendarPlus, LogIn } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { auth } from "@/auth";
|
|
import { AppointmentCard } from "@/components/ui/appointment-card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { EmptyState } from "@/components/feedback/empty-state";
|
|
import { TopNav } from "@/components/navigation/top-nav";
|
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
|
import { prisma } from "@/lib/db/prisma";
|
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
|
import { getUnreadNotificationCount } from "@/services/notifications/notification.service";
|
|
import { BookAgainButton } from "@/features/booking/components/book-again-button";
|
|
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
|
|
import { InlineNotificationBell } from "@/features/notifications/components/notification-bell";
|
|
import type { AppointmentStatus } from "@/types/domain";
|
|
|
|
export const metadata: Metadata = { title: "Appointments" };
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
|
PENDING: "pending_approval",
|
|
CONFIRMED: "confirmed",
|
|
CHECKED_IN: "arrived",
|
|
IN_PROGRESS: "in_progress",
|
|
COMPLETED: "completed",
|
|
CANCELLED: "cancelled",
|
|
NO_SHOW: "no_show",
|
|
};
|
|
|
|
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
|
|
const CANCELLED_STATUSES = ["CANCELLED", "NO_SHOW"] as const;
|
|
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
|
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
|
|
|
export default async function AppointmentsPage() {
|
|
const session = await auth();
|
|
|
|
if (!session?.user) {
|
|
return (
|
|
<div className="pb-12">
|
|
<TopNav title="Appointments" sticky className="bg-stone" />
|
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
|
<EmptyState
|
|
icon={LogIn}
|
|
title="Sign in to see your appointments"
|
|
description="Create an account or sign in to view, reschedule, and cancel your bookings."
|
|
action={
|
|
<Button asChild>
|
|
<Link href="/sign-in?callbackUrl=/appointments">Sign In</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const [business, unreadNotificationCount] = await Promise.all([
|
|
getPrimaryBusiness(),
|
|
getUnreadNotificationCount(prisma, session.user.id),
|
|
]);
|
|
const customerProfile = await prisma.customerProfile.findUnique({
|
|
where: { userId: session.user.id },
|
|
});
|
|
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
|
|
|
|
if (!customerProfile) {
|
|
return (
|
|
<div className="pb-12">
|
|
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
|
<EmptyState
|
|
icon={CalendarPlus}
|
|
title="No appointments yet"
|
|
description="Book your first appointment to see it here."
|
|
action={
|
|
<Button asChild>
|
|
<Link href="/book">Book Now</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const [upcoming, past, cancelled] = await Promise.all([
|
|
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...UPCOMING_STATUSES] }),
|
|
listAppointments(prisma, { customerId: customerProfile.id, statuses: ["COMPLETED"] }),
|
|
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...CANCELLED_STATUSES] }),
|
|
]);
|
|
|
|
const renderList = (appointments: typeof upcoming, kind: "upcoming" | "past" | "cancelled") => {
|
|
if (appointments.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon={CalendarPlus}
|
|
title={
|
|
kind === "upcoming"
|
|
? "No upcoming appointments"
|
|
: kind === "past"
|
|
? "No past appointments"
|
|
: "No cancelled appointments"
|
|
}
|
|
description={kind === "upcoming" ? "Book your next visit in a few taps." : undefined}
|
|
action={
|
|
kind === "upcoming" ? (
|
|
<Button asChild>
|
|
<Link href="/book">Book Now</Link>
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{appointments.map((appointment) => (
|
|
<AppointmentCard
|
|
key={appointment.id}
|
|
status={STATUS_MAP[appointment.status] ?? "confirmed"}
|
|
serviceName={appointment.services.map((s) => s.service.name).join(" + ")}
|
|
person={{ id: appointment.staffId, name: appointment.staff.user.name ?? "Barber" }}
|
|
formattedDate={appointment.startAt.toLocaleDateString("en-GB", {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
timeZone: business.timezone,
|
|
})}
|
|
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
timeZone: business.timezone,
|
|
})}
|
|
actions={
|
|
<div className="flex flex-1 flex-wrap gap-2">
|
|
<Link
|
|
href={`/appointments/${appointment.id}`}
|
|
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
|
>
|
|
View
|
|
</Link>
|
|
{kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
|
<Link
|
|
href={`/appointments/${appointment.id}/reschedule`}
|
|
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
|
>
|
|
Reschedule
|
|
</Link>
|
|
)}
|
|
{kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && (
|
|
<CancelAppointmentDialog appointmentId={appointment.id} />
|
|
)}
|
|
{kind !== "upcoming" && <BookAgainButton appointmentId={appointment.id} />}
|
|
</div>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="pb-12">
|
|
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
|
<div className="mx-auto max-w-2xl px-6 pt-6">
|
|
<Tabs defaultValue="upcoming">
|
|
<TabsList>
|
|
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
|
<TabsTrigger value="past">Past</TabsTrigger>
|
|
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
|
|
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
|
|
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|