Scaled back google reviews to just get the star rating and number of reviews as google limits the number of reviews to 5. Also removed the reviews tab from homepage. And updated Appointments button layout
191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { CalendarPlus, Phone } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { notFound } from "next/navigation";
|
|
import { auth } from "@/auth";
|
|
import { Card } from "@/components/ui/card";
|
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
import { TopNav } from "@/components/navigation/top-nav";
|
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
|
import { prisma } from "@/lib/db/prisma";
|
|
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
|
import {
|
|
assertCanAccessAppointment,
|
|
PermissionDeniedError,
|
|
} from "@/features/booking/authorization";
|
|
import { BookAgainButton } from "@/features/booking/components/book-again-button";
|
|
import { CancelAppointmentDialog } from "@/features/booking/components/cancel-appointment-dialog";
|
|
import type { AppointmentStatus } from "@/types/domain";
|
|
|
|
export const metadata: Metadata = { title: "Appointment Details" };
|
|
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 RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
|
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
|
const REBOOKABLE_STATUSES = new Set(["COMPLETED", "CANCELLED", "NO_SHOW"]);
|
|
|
|
export default async function AppointmentDetailsPage({
|
|
params,
|
|
searchParams,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
searchParams: Promise<{ token?: string }>;
|
|
}) {
|
|
const { id } = await params;
|
|
const { token } = await searchParams;
|
|
const appointment = await getAppointmentById(prisma, id);
|
|
if (!appointment) notFound();
|
|
|
|
const session = await auth();
|
|
try {
|
|
await assertCanAccessAppointment(prisma, appointment, { sessionUserId: session?.user?.id, guestToken: token });
|
|
} catch (error) {
|
|
if (error instanceof PermissionDeniedError) notFound(); // don't confirm the id exists to someone who can't see it
|
|
throw error;
|
|
}
|
|
|
|
const business = await getPrimaryBusiness();
|
|
const totalDuration = appointment.services.reduce(
|
|
(sum, s) => sum + s.durationAtBookingMinutes,
|
|
0,
|
|
);
|
|
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
|
let cancelledByLabel: string | null = null;
|
|
if (appointment.status === "CANCELLED" && appointment.cancelledByUserId) {
|
|
const canceller = await prisma.user.findUnique({
|
|
where: { id: appointment.cancelledByUserId },
|
|
select: { name: true },
|
|
});
|
|
cancelledByLabel = canceller?.name ?? null;
|
|
}
|
|
|
|
return (
|
|
<div className="pb-12">
|
|
<TopNav title="Appointment Details" onBack={true} sticky className="bg-stone" />
|
|
|
|
<main className="mx-auto max-w-2xl space-y-4 px-6 py-6">
|
|
<Card className="space-y-6 p-6">
|
|
<div className="flex items-center justify-between">
|
|
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
|
|
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
|
Ref: {appointment.id.slice(-8)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="border-line space-y-1 border-t pt-4">
|
|
<p className="text-lg font-bold">
|
|
{appointment.services.map((s) => s.service.name).join(" + ")}
|
|
</p>
|
|
<p className="text-muted text-sm">
|
|
with {appointment.staff.user.name} · {business.name}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-line grid grid-cols-2 gap-4 border-t pt-4 text-sm">
|
|
<div>
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Date
|
|
</span>
|
|
{appointment.startAt.toLocaleDateString("en-GB", {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
timeZone: business.timezone,
|
|
})}
|
|
</div>
|
|
<div>
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Time
|
|
</span>
|
|
{appointment.startAt.toLocaleTimeString("en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
timeZone: business.timezone,
|
|
})}
|
|
</div>
|
|
<div>
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Duration
|
|
</span>
|
|
{totalDuration} min
|
|
</div>
|
|
<div>
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Total
|
|
</span>
|
|
£{totalPrice.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
|
|
{appointment.notes && (
|
|
<div className="border-line border-t pt-4">
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Your Notes
|
|
</span>
|
|
<p className="text-muted mt-1 text-sm italic">{appointment.notes}</p>
|
|
</div>
|
|
)}
|
|
|
|
{appointment.status === "CANCELLED" && (
|
|
<div className="border-line border-t pt-4 text-sm">
|
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
|
Cancellation
|
|
</span>
|
|
<p className="text-muted mt-1">
|
|
Cancelled{" "}
|
|
{appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: business.timezone })}
|
|
{cancelledByLabel ? ` by ${cancelledByLabel}` : ""}
|
|
</p>
|
|
{appointment.cancellationReason && (
|
|
<p className="text-muted mt-1 italic">
|
|
“{appointment.cancellationReason}”
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<a
|
|
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
|
|
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
|
>
|
|
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
|
</a>
|
|
{business.phone && (
|
|
<a
|
|
href={`tel:${business.phone}`}
|
|
className="rounded-md border-line flex w-full items-center justify-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
|
>
|
|
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
|
|
</a>
|
|
)}
|
|
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
|
<Link
|
|
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
|
|
className="rounded-md border-line block w-full border bg-white px-5 py-3 text-center text-sm font-bold"
|
|
>
|
|
Reschedule
|
|
</Link>
|
|
)}
|
|
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
|
<CancelAppointmentDialog appointmentId={appointment.id} guestToken={token} className="w-full" />
|
|
)}
|
|
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
|
<BookAgainButton appointmentId={appointment.id} guestToken={token} className="w-full" />
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|