Phase 4
Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres. 14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing. Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically. Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds): A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own). A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice. A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3. Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled. Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
66 files changed
+4599
-107
No files matched your search
@@ -0,0 +1,190 @@
|
||||
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,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
||||
} 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-wrap gap-3">
|
||||
<a
|
||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||
className="rounded-jumbo border-line flex items-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-jumbo border-line flex items-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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||
<Link
|
||||
href={`/appointments/${appointment.id}/reschedule`}
|
||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||
>
|
||||
Reschedule
|
||||
</Link>
|
||||
)}
|
||||
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
||||
<CancelAppointmentDialog appointmentId={appointment.id} />
|
||||
)}
|
||||
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
||||
<BookAgainButton appointmentId={appointment.id} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,170 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { ComingSoon } from "@/components/feedback/coming-soon";
|
||||
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 { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||
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: "Appointments" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
return <ComingSoon icon={Calendar} title="Appointments" phase="Phase 2: Customer Accounts" />;
|
||||
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="mx-auto max-w-2xl px-6 pt-12">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const business = await getPrimaryBusiness();
|
||||
const customerProfile = await prisma.customerProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
if (!customerProfile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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(" + ")}
|
||||
staff={{ 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="mx-auto max-w-2xl px-6 pt-12">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Appointments</h1>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,371 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Star } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { BarberCard } from "@/components/ui/barber-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { computeOpeningStatus } from "@/lib/business/opening-status";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Home" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WEEKDAY_ORDER: Weekday[] = [
|
||||
Weekday.MONDAY,
|
||||
Weekday.TUESDAY,
|
||||
Weekday.WEDNESDAY,
|
||||
Weekday.THURSDAY,
|
||||
Weekday.FRIDAY,
|
||||
Weekday.SATURDAY,
|
||||
Weekday.SUNDAY,
|
||||
];
|
||||
const WEEKDAY_LABEL: Record<Weekday, string> = {
|
||||
MONDAY: "Monday",
|
||||
TUESDAY: "Tuesday",
|
||||
WEDNESDAY: "Wednesday",
|
||||
THURSDAY: "Thursday",
|
||||
FRIDAY: "Friday",
|
||||
SATURDAY: "Saturday",
|
||||
SUNDAY: "Sunday",
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
PENDING: "pending_approval",
|
||||
CONFIRMED: "confirmed",
|
||||
CHECKED_IN: "arrived",
|
||||
IN_PROGRESS: "in_progress",
|
||||
};
|
||||
|
||||
export default async function CustomerHomePage() {
|
||||
const session = await auth();
|
||||
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
|
||||
|
||||
const [services, staff, reviews] = await Promise.all([
|
||||
getAvailableServices(prisma, { businessId: business.id }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
|
||||
include: { user: { select: { name: true, image: true } } },
|
||||
}),
|
||||
getBusinessReviews(business.id),
|
||||
]);
|
||||
|
||||
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
|
||||
const location = business.locations[0];
|
||||
const previewServices = services.slice(0, 4);
|
||||
|
||||
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
|
||||
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
|
||||
}>;
|
||||
|
||||
let customerProfile: Awaited<ReturnType<typeof prisma.customerProfile.findUnique>> = null;
|
||||
let upcomingAppointment: UpcomingAppointment | null = null;
|
||||
let loyaltyAccount: Awaited<ReturnType<typeof prisma.loyaltyAccount.findFirst>> = null;
|
||||
let loyaltyProgramme: Awaited<ReturnType<typeof prisma.loyaltyProgramme.findUnique>> = null;
|
||||
let preferredBarberName: string | null = null;
|
||||
let lastCompletedAppointment: Awaited<ReturnType<typeof prisma.appointment.findFirst>> = null;
|
||||
|
||||
if (session?.user?.id) {
|
||||
customerProfile = await prisma.customerProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
if (customerProfile) {
|
||||
[upcomingAppointment, loyaltyProgramme, lastCompletedAppointment] = await Promise.all([
|
||||
prisma.appointment.findFirst({
|
||||
where: {
|
||||
customerId: customerProfile.id,
|
||||
status: { in: ["PENDING", "CONFIRMED", "CHECKED_IN"] },
|
||||
startAt: { gte: new Date() },
|
||||
},
|
||||
orderBy: { startAt: "asc" },
|
||||
include: { services: { include: { service: true } }, staff: { include: { user: true } } },
|
||||
}),
|
||||
prisma.loyaltyProgramme.findUnique({ where: { businessId: business.id } }),
|
||||
prisma.appointment.findFirst({
|
||||
where: { customerId: customerProfile.id, status: "COMPLETED" },
|
||||
orderBy: { startAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (loyaltyProgramme?.isEnabled) {
|
||||
loyaltyAccount = await prisma.loyaltyAccount.findUnique({
|
||||
where: {
|
||||
customerId_loyaltyProgrammeId: {
|
||||
customerId: customerProfile.id,
|
||||
loyaltyProgrammeId: loyaltyProgramme.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (customerProfile.preferredBarberId) {
|
||||
const preferred = await prisma.staffProfile.findUnique({
|
||||
where: { id: customerProfile.preferredBarberId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
preferredBarberName = preferred?.user.name ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12">
|
||||
<p className="text-xs font-medium text-muted">Signed in as</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{session?.user?.name ?? session?.user?.email}</h1>
|
||||
<p className="mt-6 text-sm text-muted">
|
||||
The full personalized home (upcoming appointment, loyalty progress, quick booking) is designed
|
||||
in <code>templates/23-...html</code> and scoped for a later phase — this route exists now to
|
||||
prove sign-in → protected layout → bottom nav works end to end.
|
||||
</p>
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="bg-ink relative h-56 w-full overflow-hidden">
|
||||
{business.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={business.logoUrl} alt="" className="h-full w-full object-cover opacity-80" />
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<main className="mx-auto max-w-2xl bg-white px-6 py-6">
|
||||
{session?.user && (
|
||||
<p className="text-muted mb-1 text-sm">
|
||||
Good to see you, {session.user.name?.split(" ")[0] ?? "there"}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="mb-1 text-2xl font-bold tracking-tight">{business.name}</h1>
|
||||
{location && (
|
||||
<p className="text-muted mb-3 text-xs">
|
||||
{location.addressLine1}, {location.city} {location.postalCode}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-4 flex items-center gap-3 text-sm">
|
||||
<div className="text-accent-loyalty flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
|
||||
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
|
||||
</div>
|
||||
<span className="text-muted">· {reviews.totalReviews.toLocaleString()} reviews</span>
|
||||
<span
|
||||
className={openingStatus.isOpen ? "text-accent font-bold" : "text-danger font-bold"}
|
||||
>
|
||||
{openingStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
{business.description && (
|
||||
<p className="text-muted mb-6 text-sm leading-relaxed">{business.description}</p>
|
||||
)}
|
||||
|
||||
{/* Upcoming appointment */}
|
||||
{upcomingAppointment && (
|
||||
<section className="mb-6">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Your Next Visit
|
||||
</h2>
|
||||
<AppointmentCard
|
||||
status={STATUS_MAP[upcomingAppointment.status] ?? "confirmed"}
|
||||
serviceName={upcomingAppointment.services.map((s) => s.service.name).join(" + ")}
|
||||
staff={{
|
||||
id: upcomingAppointment.staffId,
|
||||
name: upcomingAppointment.staff.user.name ?? "Barber",
|
||||
}}
|
||||
formattedDate={upcomingAppointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
formattedTime={upcomingAppointment.startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
actions={
|
||||
<Link
|
||||
href="/appointments"
|
||||
className="text-ink text-xs font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Loyalty summary */}
|
||||
{loyaltyProgramme?.isEnabled && loyaltyAccount && (
|
||||
<Link
|
||||
href="/settings"
|
||||
className="rounded-jumbo border-line mb-6 block border bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-bold">Loyalty Progress</p>
|
||||
<span className="text-muted text-xs">
|
||||
{loyaltyAccount.currentStamps}/{loyaltyProgramme.stampsRequired} stamps
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-accent-loyalty h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, (loyaltyAccount.currentStamps / loyaltyProgramme.stampsRequired) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Preferred barber + book again */}
|
||||
{(preferredBarberName || lastCompletedAppointment) && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3">
|
||||
{preferredBarberName && (
|
||||
<Card className="p-4">
|
||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Preferred Barber
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-bold">{preferredBarberName}</p>
|
||||
</Card>
|
||||
)}
|
||||
{lastCompletedAppointment && (
|
||||
<Button asChild variant="secondary" className="h-full w-full">
|
||||
<Link href="/appointments">Book Again</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking CTA */}
|
||||
<Button asChild className="mb-8 w-full">
|
||||
<Link href="/book">Book an Appointment</Link>
|
||||
</Button>
|
||||
|
||||
{/* Services */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||
Popular Services
|
||||
</h2>
|
||||
<Link
|
||||
href="/book"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{previewServices.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/book">Book</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Barbers */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Our Barbers
|
||||
</h2>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard
|
||||
key={s.id}
|
||||
staff={{
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
}}
|
||||
isPreferred={customerProfile?.preferredBarberId === s.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reviews */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Reviews</h2>
|
||||
{reviews.isDemoData && (
|
||||
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
|
||||
Demo data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{reviews.reviews.map((review) => (
|
||||
<Card key={review.id} className="p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<span className="text-muted text-[10px]">{review.relativeDate}</span>
|
||||
</div>
|
||||
<div className="text-accent-loyalty mb-2 flex gap-0.5">
|
||||
{Array.from({ length: review.rating }, (_, i) => (
|
||||
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted text-xs">{review.text}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hours */}
|
||||
<section className="mb-4">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Opening Hours
|
||||
</h2>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
return (
|
||||
<div key={day} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{WEEKDAY_LABEL[day]}</span>
|
||||
<span className="font-medium">
|
||||
{!dayHours ||
|
||||
dayHours.isClosed ||
|
||||
dayHours.opensAtMinutes === null ||
|
||||
dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Contact</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{business.phone && (
|
||||
<a
|
||||
href={`tel:${business.phone}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Call
|
||||
</a>
|
||||
)}
|
||||
{business.email && (
|
||||
<a
|
||||
href={`mailto:${business.email}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { BottomNav, type BottomNavItem } from "@/components/navigation/bottom-nav";
|
||||
|
||||
const NAV_ITEMS: BottomNavItem[] = [
|
||||
@@ -8,12 +6,14 @@ const NAV_ITEMS: BottomNavItem[] = [
|
||||
{ href: "/settings", label: "Settings", icon: "user" },
|
||||
];
|
||||
|
||||
export default async function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/sign-in?callbackUrl=/home");
|
||||
}
|
||||
|
||||
/**
|
||||
* No auth gate here — Home must work for guests too (the Phase 4 brief:
|
||||
* "For guests, show the public business experience without personalised
|
||||
* content"). /appointments and /settings each check their own session
|
||||
* requirement and show an in-context sign-in prompt rather than bouncing
|
||||
* a guest away with no explanation — see those pages.
|
||||
*/
|
||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone pb-24">
|
||||
{children}
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LogIn } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/feedback/empty-state";
|
||||
import { ProfileCard } from "@/components/ui/profile-card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||
|
||||
export const metadata: Metadata = { title: "Settings" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CustomerSettingsPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-6 pt-12">
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<EmptyState
|
||||
icon={LogIn}
|
||||
title="Sign in to manage your account"
|
||||
description="Save your details, track loyalty rewards, and manage your bookings."
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/sign-in?callbackUrl=/settings">Sign In</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customerProfile = await prisma.customerProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6 pt-12">
|
||||
<div className="mx-auto max-w-2xl space-y-6 px-6 pt-12">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
<ProfileCard
|
||||
name={session?.user?.name ?? "Signed-in customer"}
|
||||
meta={session?.user?.email}
|
||||
avatarUrl={session?.user?.image ?? undefined}
|
||||
name={
|
||||
customerProfile
|
||||
? `${customerProfile.firstName} ${customerProfile.lastName}`
|
||||
: (session.user.name ?? "Signed-in customer")
|
||||
}
|
||||
meta={customerProfile?.email ?? session.user.email}
|
||||
avatarUrl={session.user.image ?? undefined}
|
||||
/>
|
||||
<p className="text-sm text-muted">
|
||||
<p className="text-muted text-sm">
|
||||
Preferred barber, loyalty card, and notification preferences are designed in
|
||||
<code> templates/10, 11, 35</code> and land in later phases.
|
||||
<code> templates/10, 11, 35</code> and land in a later phase.
|
||||
</p>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { generateAppointmentIcs } from "@/lib/calendar/ics";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { assertCanAccessAppointment, PermissionDeniedError } from "@/features/booking/authorization";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
|
||||
/**
|
||||
* A file download needs real HTTP response headers (Content-Type,
|
||||
* Content-Disposition) that a Server Action can't produce — the one
|
||||
* legitimate use of a Route Handler in this phase, per Phase 2's
|
||||
* architecture ("reserve app/api for... anything that needs a typed HTTP
|
||||
* response"). Still calls straight into AppointmentService/ics.ts —
|
||||
* no booking logic lives here.
|
||||
*/
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) {
|
||||
return NextResponse.json({ error: "Appointment not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const business = await prisma.business.findUnique({
|
||||
where: { id: appointment.businessId },
|
||||
include: { locations: true },
|
||||
});
|
||||
|
||||
const ics = generateAppointmentIcs({
|
||||
appointmentId: appointment.id,
|
||||
businessName: business?.name ?? "NextChair",
|
||||
businessAddress: business?.locations[0]
|
||||
? `${business.locations[0].addressLine1}, ${business.locations[0].city} ${business.locations[0].postalCode}`
|
||||
: undefined,
|
||||
staffName: appointment.staff.user.name ?? "Your barber",
|
||||
serviceNames: appointment.services.map((s) => s.service.name),
|
||||
startAt: appointment.startAt,
|
||||
endAt: appointment.endAt,
|
||||
notes: appointment.notes ?? undefined,
|
||||
});
|
||||
|
||||
return new NextResponse(ics, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="appointment-${appointment.id}.ics"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Deliberately OUTSIDE the (customer) route group, same reasoning as
|
||||
* src/app/book/layout.tsx: this page reuses BarberDateTimePicker, which
|
||||
* renders its own fixed bottom action bar. Nested under (customer)'s
|
||||
* persistent BottomNav, the two fixed-bottom elements would overlap —
|
||||
* the nav (z-50) sits above the picker's "Confirm Reschedule" bar (z-40)
|
||||
* and swallows clicks meant for it. Hiding the bottom nav here avoids
|
||||
* that collision, matching the booking flow's own "may temporarily hide
|
||||
* the bottom navigation" allowance.
|
||||
*/
|
||||
export default function RescheduleLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-stone">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
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 { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
|
||||
import { rescheduleCustomerAppointmentAction } from "@/features/booking/actions/customer-appointments";
|
||||
|
||||
export const metadata: Metadata = { title: "Reschedule Appointment" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const NON_RESCHEDULABLE = new Set(["COMPLETED", "CANCELLED", "NO_SHOW", "IN_PROGRESS"]);
|
||||
|
||||
export default async function RescheduleAppointmentPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
const session = await auth();
|
||||
try {
|
||||
assertCanAccessAppointment(appointment, session?.user?.id);
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (NON_RESCHEDULABLE.has(appointment.status)) {
|
||||
redirect(`/appointments/${id}`);
|
||||
}
|
||||
|
||||
const business = await getPrimaryBusiness();
|
||||
|
||||
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
|
||||
"use server";
|
||||
const result = await rescheduleCustomerAppointmentAction({
|
||||
appointmentId: id,
|
||||
staffId: selection.staffId,
|
||||
startAt: selection.startAt,
|
||||
});
|
||||
if (result.success) {
|
||||
redirect(`/appointments/${id}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Reschedule" onBack={true} sticky className="bg-stone" />
|
||||
|
||||
<Card className="mx-6 mt-4 flex items-center gap-3 p-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold">{appointment.services.map((s) => s.service.name).join(" + ")} with {appointment.staff.user.name}</p>
|
||||
<p className="text-xs text-muted">
|
||||
Currently: {appointment.startAt.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: business.timezone })} ·{" "}
|
||||
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<BarberDateTimePicker
|
||||
businessId={business.id}
|
||||
businessTimezone={business.timezone}
|
||||
serviceIds={appointment.services.map((s) => s.serviceId)}
|
||||
initialStaffId={appointment.staffId}
|
||||
onContinue={handleContinue}
|
||||
continueLabel="Confirm Reschedule"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { readDraft } from "@/lib/booking-draft/cookie";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { BarberDateTimePicker } from "@/features/booking/components/barber-date-time-picker";
|
||||
import { saveBarberTimeStepAction } from "@/features/booking/actions/draft";
|
||||
|
||||
export const metadata: Metadata = { title: "Barber & Time" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BookBarberTimePage() {
|
||||
const business = await getPrimaryBusiness();
|
||||
const draft = await readDraft(business.id);
|
||||
|
||||
if (draft.serviceIds.length === 0) {
|
||||
redirect("/book");
|
||||
}
|
||||
|
||||
async function handleContinue(selection: { staffId?: string; date: string; startAt: string }) {
|
||||
"use server";
|
||||
return saveBarberTimeStepAction({ businessId: business.id, ...selection });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Barber & Time" onBack={true} sticky className="bg-stone" />
|
||||
<BarberDateTimePicker
|
||||
businessId={business.id}
|
||||
businessTimezone={business.timezone}
|
||||
serviceIds={draft.serviceIds}
|
||||
initialStaffId={draft.staffId}
|
||||
initialDate={draft.date}
|
||||
onContinue={handleContinue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CalendarPlus, Check } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StatusPill } from "@/components/ui/status-pill";
|
||||
import { readLastBookingReference } from "@/lib/booking-draft/cookie";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Booking Confirmed" };
|
||||
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",
|
||||
};
|
||||
|
||||
export default async function BookConfirmationPage() {
|
||||
const appointmentId = await readLastBookingReference();
|
||||
if (!appointmentId) redirect("/home");
|
||||
|
||||
const appointment = await getAppointmentById(prisma, appointmentId);
|
||||
if (!appointment) redirect("/home");
|
||||
|
||||
const business = await getPrimaryBusiness();
|
||||
const session = await auth();
|
||||
const totalPrice = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center px-6 pb-16 pt-12">
|
||||
<div className="mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-accent text-white shadow-lg shadow-accent/20">
|
||||
<Check className="h-9 w-9" aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-center text-2xl font-bold">You're all set!</h1>
|
||||
<p role="status" className="mb-8 text-center text-sm text-muted">
|
||||
We've confirmed your booking with {business.name}.
|
||||
</p>
|
||||
|
||||
<Card className="w-full max-w-md space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusPill status={STATUS_MAP[appointment.status] ?? "confirmed"} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted">Ref: {appointment.id.slice(-8)}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 border-t border-line pt-4">
|
||||
<p className="text-sm font-bold">{appointment.services.map((s) => s.service.name).join(" + ")}</p>
|
||||
<p className="text-xs text-muted">with {appointment.staff.user.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 border-t border-line pt-4 text-sm">
|
||||
<div>
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Date</span>
|
||||
{appointment.startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Time</span>
|
||||
{appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })}
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Duration</span>
|
||||
{appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0)} min
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted">Total</span>£{totalPrice.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-jumbo border border-line bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<div className="mt-8 w-full max-w-md space-y-3">
|
||||
{session?.user ? (
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href="/appointments">View Appointment</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Card className="space-y-3 p-5 text-center">
|
||||
<p className="text-sm font-bold">Want to manage this booking later?</p>
|
||||
<p className="text-xs text-muted">
|
||||
Create an account to view your appointment history, save your details, and track loyalty rewards.
|
||||
</p>
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href="/register">Create an Account</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<Button asChild variant="secondary" className="w-full">
|
||||
<Link href="/book">Book Another Appointment</Link>
|
||||
</Button>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/home">Return Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { readDraft } from "@/lib/booking-draft/cookie";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { DetailsForm } from "@/features/booking/components/details-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Your Details" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BookDetailsPage() {
|
||||
const business = await getPrimaryBusiness();
|
||||
const draft = await readDraft(business.id);
|
||||
|
||||
if (draft.serviceIds.length === 0) redirect("/book");
|
||||
if (!draft.startAt) redirect("/book/barber-time");
|
||||
|
||||
const session = await auth();
|
||||
let prefill: { firstName: string; lastName: string; email: string; phone: string } | undefined;
|
||||
|
||||
if (session?.user?.id) {
|
||||
const profile = await prisma.customerProfile.findUnique({ where: { userId: session.user.id } });
|
||||
prefill = {
|
||||
firstName: profile?.firstName ?? session.user.name?.split(" ")[0] ?? "",
|
||||
lastName: profile?.lastName ?? session.user.name?.split(" ").slice(1).join(" ") ?? "",
|
||||
email: profile?.email ?? session.user.email ?? "",
|
||||
phone: profile?.phone ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<TopNav title="Your Details" onBack={true} sticky className="bg-stone" />
|
||||
<DetailsForm
|
||||
businessId={business.id}
|
||||
cancellationPolicy={business.settings?.cancellationPolicy ?? undefined}
|
||||
isAuthenticated={!!session?.user}
|
||||
prefill={prefill}
|
||||
initialNotes={draft.notes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Deliberately OUTSIDE the (customer) route group — the booking flow must
|
||||
* work for guests, and (customer)/layout.tsx's session check doesn't
|
||||
* apply here. The bottom nav is hidden throughout, matching the focused,
|
||||
* single-purpose flow in the booking templates (18, 9, 17, 26, 27) — see
|
||||
* docs/phase-4-summary.md for why that's a deliberate, brief-sanctioned
|
||||
* choice ("may temporarily hide the bottom navigation where this
|
||||
* improves focus").
|
||||
*/
|
||||
export default function BookLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-stone">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { readDraft } from "@/lib/booking-draft/cookie";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||
import { ServiceSelector } from "@/features/booking/components/service-selector";
|
||||
|
||||
export const metadata: Metadata = { title: "Select Services" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BookServicesPage() {
|
||||
const business = await getPrimaryBusiness();
|
||||
const [services, categories, draft] = await Promise.all([
|
||||
getAvailableServices(prisma, { businessId: business.id }),
|
||||
prisma.serviceCategory.findMany({
|
||||
where: { businessId: business.id, isActive: true },
|
||||
orderBy: { displayOrder: "asc" },
|
||||
}),
|
||||
readDraft(business.id),
|
||||
]);
|
||||
|
||||
const categoryNames = new Map(categories.map((c) => [c.id, c.name]));
|
||||
const orderedCategoryIds = categories.map((c) => c.id);
|
||||
|
||||
return (
|
||||
<ServiceSelector
|
||||
businessId={business.id}
|
||||
services={services}
|
||||
categoryNames={Object.fromEntries(categoryNames)}
|
||||
categoryOrder={orderedCategoryIds}
|
||||
initialSelectedServiceIds={draft.serviceIds}
|
||||
rebooking={!!draft.rebookFromAppointmentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { readDraft } from "@/lib/booking-draft/cookie";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { ConfirmBookingButton } from "@/features/booking/components/confirm-booking-button";
|
||||
|
||||
export const metadata: Metadata = { title: "Review Booking" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BookReviewPage() {
|
||||
const business = await getPrimaryBusiness();
|
||||
const draft = await readDraft(business.id);
|
||||
|
||||
if (draft.serviceIds.length === 0) redirect("/book");
|
||||
if (!draft.startAt) redirect("/book/barber-time");
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user && !draft.guest) redirect("/book/details");
|
||||
if (!draft.policyAcknowledged) redirect("/book/details");
|
||||
|
||||
const [services, staff, customerProfile] = await Promise.all([
|
||||
prisma.service.findMany({ where: { id: { in: draft.serviceIds } } }),
|
||||
draft.staffId
|
||||
? prisma.staffProfile.findUnique({
|
||||
where: { id: draft.staffId },
|
||||
include: { user: { select: { name: true } } },
|
||||
})
|
||||
: null,
|
||||
session?.user?.id
|
||||
? prisma.customerProfile.findUnique({ where: { userId: session.user.id } })
|
||||
: null,
|
||||
]);
|
||||
|
||||
const totalDuration = services.reduce((sum, s) => sum + s.durationMinutes, 0);
|
||||
const totalPrice = services.reduce((sum, s) => sum + Number(s.price), 0);
|
||||
const startAt = new Date(draft.startAt);
|
||||
const endAt = new Date(startAt.getTime() + totalDuration * 60_000);
|
||||
|
||||
const customerName = draft.guest
|
||||
? `${draft.guest.firstName} ${draft.guest.lastName}`
|
||||
: `${customerProfile?.firstName ?? session?.user?.name ?? ""} ${customerProfile?.lastName ?? ""}`.trim();
|
||||
const customerEmail = draft.guest?.email ?? customerProfile?.email ?? session?.user?.email ?? "";
|
||||
const customerPhone = draft.guest?.phone ?? customerProfile?.phone ?? "";
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Review Booking" 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-start justify-between">
|
||||
<span className="text-muted text-[10px] font-bold tracking-widest uppercase">
|
||||
Business
|
||||
</span>
|
||||
<Link
|
||||
href="/home"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
{business.name}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border-line space-y-4 border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Services
|
||||
</span>
|
||||
<ul className="mt-1 text-sm font-bold">
|
||||
{services.map((s) => (
|
||||
<li key={s.id}>{s.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Link
|
||||
href="/book"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Barber
|
||||
</span>
|
||||
<p className="text-sm font-bold">{staff?.user.name ?? "Any available barber"}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/book/barber-time"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Date & Time
|
||||
</span>
|
||||
<p className="text-sm font-bold">
|
||||
{startAt.toLocaleDateString("en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
{" · "}
|
||||
{startAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
{"–"}
|
||||
{endAt.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone: business.timezone,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/book/barber-time"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line grid grid-cols-2 gap-6 border-t pt-6">
|
||||
<div>
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Duration
|
||||
</span>
|
||||
<p className="text-sm font-bold">{totalDuration} min</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Total
|
||||
</span>
|
||||
<p className="text-sm font-bold">£{totalPrice.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-line border-t pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Your Details
|
||||
</span>
|
||||
<Link
|
||||
href="/book/details"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-bold">{customerName}</p>
|
||||
<p className="text-muted text-xs">
|
||||
{customerEmail}
|
||||
{customerPhone ? ` · ${customerPhone}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{draft.notes && (
|
||||
<div className="border-line border-t pt-6">
|
||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||
Notes
|
||||
</span>
|
||||
<p className="text-muted mt-1 text-sm italic">{draft.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConfirmBookingButton businessId={business.id} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default async function SchedulingTestPage() {
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="p-8 text-sm text-red-500">
|
||||
<div className="text-danger p-8 text-sm">
|
||||
No seeded customer found — run <code>npm run prisma:seed</code> first.
|
||||
</div>
|
||||
);
|
||||
@@ -47,7 +47,12 @@ export default async function SchedulingTestPage() {
|
||||
return (
|
||||
<SchedulingTestClient
|
||||
business={business}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name, durationMinutes: s.durationMinutes, price: s.price.toString() }))}
|
||||
services={services.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
}))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
|
||||
@@ -42,8 +42,19 @@ interface TimeSlot {
|
||||
staffOptions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar-date components, not `.toISOString().slice(0, 10)` — that
|
||||
* converts to UTC first, which silently shifts the date back a day for
|
||||
* anyone in a timezone ahead of UTC (e.g. the business's own Europe/London
|
||||
* during BST). `date` here is always local midnight from MonthCalendar's
|
||||
* own `new Date(year, month, day)`, so reading the local parts back out is
|
||||
* the correct inverse.
|
||||
*/
|
||||
function toYMD(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
const EMPTY_SET: Set<string> = new Set();
|
||||
|
||||
+11
-1
@@ -18,10 +18,20 @@
|
||||
--color-accent: #10b981;
|
||||
--color-accent-loyalty: #f59e0b;
|
||||
|
||||
/* Generic UI-state colors — destructive actions, form-validation errors,
|
||||
and warning notices. Deliberately separate variables from the
|
||||
status-* tokens below even though they share a hue today: an
|
||||
appointment being CANCELLED and a form field being INVALID are
|
||||
different concepts that a future theme may want to diverge on. */
|
||||
--color-danger: #ef4444;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-foreground: #92400e;
|
||||
|
||||
/* Semantic status colors — replaces the ad hoc per-screen status colors
|
||||
found in the templates (see docs/implementation-plan.md §8). */
|
||||
--color-status-confirmed: #10b981;
|
||||
--color-status-pending: #f59e0b;
|
||||
--color-status-progress: #2563eb;
|
||||
--color-status-cancelled: #ef4444;
|
||||
--color-status-completed: #6c7870;
|
||||
|
||||
@@ -44,7 +54,7 @@
|
||||
no explicit focus state beyond the browser default on form inputs
|
||||
(see docs/implementation-plan.md §8, Accessibility risks). */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-ink ring-offset-2 ring-offset-stone;
|
||||
@apply ring-ink ring-offset-stone ring-2 ring-offset-2 outline-none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user