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:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
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">
&ldquo;{appointment.cancellationReason}&rdquo;
</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>
);
}
+165 -4
View File
@@ -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>
);
}
+360 -9
View File
@@ -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>
);
}
+8 -8
View File
@@ -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}
+38 -6
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+116
View File
@@ -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&apos;re all set!</h1>
<p role="status" className="mb-8 text-center text-sm text-muted">
We&apos;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>
);
}
+45
View File
@@ -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>
);
}
+12
View File
@@ -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>;
}
+35
View File
@@ -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}
/>
);
}
+186
View File
@@ -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 &amp; 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>
);
}
+7 -2
View File
@@ -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
View File
@@ -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;
}
}
+2 -2
View File
@@ -15,11 +15,11 @@ export function ErrorState({
}: ErrorStateProps) {
return (
<div role="alert" className="flex flex-col items-center px-6 py-16 text-center">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-red-50 text-red-500">
<div className="bg-danger/10 text-danger mb-5 flex h-16 w-16 items-center justify-center rounded-full">
<TriangleAlert className="h-6 w-6" aria-hidden="true" />
</div>
<h3 className="mb-1 text-base font-bold">{title}</h3>
<p className="mb-6 text-sm text-muted">{description}</p>
<p className="text-muted mb-6 text-sm">{description}</p>
{onRetry && (
<Button variant="secondary" size="md" onClick={onRetry}>
Try again
+26 -21
View File
@@ -58,30 +58,35 @@ export function BottomNav({ items, variant = "customer" }: BottomNavProps) {
<nav
aria-label="Primary"
className={cn(
"fixed inset-x-0 bottom-0 z-50 flex items-center justify-between border-t px-5 py-2",
"fixed inset-x-0 bottom-0 z-50 border-t px-5 py-2",
"pb-[max(0.5rem,env(safe-area-inset-bottom))]",
isStaff ? "border-white/5 bg-[#0c0c0c] text-gray-400" : "border-line bg-white text-muted",
isStaff ? "border-white/5 bg-[#0c0c0c] text-gray-400" : "border-line text-muted bg-white",
)}
>
{items.map(({ href, label, icon }) => {
const Icon = ICON_MAP[icon];
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
aria-current={active ? "page" : undefined}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-3 py-1 transition-colors",
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
isStaff && !active && "hover:text-white",
)}
>
<Icon className="h-5 w-5" aria-hidden="true" />
<span className="text-[10px] font-bold uppercase tracking-widest">{label}</span>
</Link>
);
})}
{/* The bar itself stays edge-to-edge (matches the booking flow's fixed
action bars); only the items inside are centered/width-capped so
they don't stretch out unreadably on a wide desktop viewport. */}
<div className="mx-auto flex w-full max-w-2xl items-center justify-between">
{items.map(({ href, label, icon }) => {
const Icon = ICON_MAP[icon];
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
aria-current={active ? "page" : undefined}
className={cn(
"flex flex-col items-center gap-1 rounded-lg px-3 py-1 transition-colors",
active ? (isStaff ? "text-white" : "text-ink") : "hover:text-ink",
isStaff && !active && "hover:text-white",
)}
>
<Icon className="h-5 w-5" aria-hidden="true" />
<span className="text-[10px] font-bold tracking-widest uppercase">{label}</span>
</Link>
);
})}
</div>
</nav>
);
}
+6 -4
View File
@@ -20,7 +20,7 @@ export const buttonVariants = cva(
accent: "bg-accent text-white hover:brightness-95",
secondary: "bg-white text-ink border border-line hover:bg-stone",
ghost: "text-ink hover:bg-stone",
destructive: "border border-red-200 text-red-500 hover:bg-red-50",
destructive: "border border-danger/30 text-danger hover:bg-danger/10",
},
size: {
sm: "h-9 rounded-full px-4 text-[10px]",
@@ -37,15 +37,17 @@ export const buttonVariants = cva(
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
/** Render the styling onto the single child element instead of a <button> (e.g. a Next <Link>). */
asChild?: boolean;
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, isLoading = false, children, disabled, ...props }, ref) => {
(
{ className, variant, size, asChild = false, isLoading = false, children, disabled, ...props },
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
+6 -6
View File
@@ -22,7 +22,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{label && (
<label
htmlFor={inputId}
className="mb-2 ml-1 block text-[10px] font-bold uppercase tracking-widest text-muted"
className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase"
>
{label}
{required && <span aria-hidden="true"> *</span>}
@@ -30,7 +30,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
)}
<div className="relative">
{icon && (
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-muted">
<span className="text-muted pointer-events-none absolute top-1/2 left-5 -translate-y-1/2">
{icon}
</span>
)}
@@ -41,21 +41,21 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
aria-invalid={!!error || undefined}
aria-describedby={cn(hintId, errorId) || undefined}
className={cn(
"w-full rounded-xl border border-line bg-white px-5 py-4 text-sm outline-none transition-all placeholder:text-muted focus:ring-2 focus:ring-ink",
"border-line placeholder:text-muted focus:ring-ink w-full rounded-xl border bg-white px-5 py-4 text-sm transition-all outline-none focus:ring-2",
icon && "pl-12",
error && "border-red-300 focus:ring-red-400",
error && "border-danger/50 focus:ring-danger",
className,
)}
{...props}
/>
</div>
{hint && !error && (
<p id={hintId} className="mt-1.5 ml-1 text-xs text-muted">
<p id={hintId} className="text-muted mt-1.5 ml-1 text-xs">
{hint}
</p>
)}
{error && (
<p id={errorId} role="alert" className="mt-1.5 ml-1 text-xs font-medium text-red-500">
<p id={errorId} role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
{error}
</p>
)}
+5 -5
View File
@@ -24,7 +24,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
{label && (
<label
htmlFor={selectId}
className="mb-2 ml-1 block text-[10px] font-bold uppercase tracking-widest text-muted"
className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase"
>
{label}
{required && <span aria-hidden="true"> *</span>}
@@ -38,8 +38,8 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
aria-invalid={!!error || undefined}
aria-describedby={errorId}
className={cn(
"w-full appearance-none rounded-xl border border-line bg-white px-4 py-3 pr-10 text-sm font-medium outline-none transition-all focus:ring-2 focus:ring-ink",
error && "border-red-300 focus:ring-red-400",
"border-line focus:ring-ink w-full appearance-none rounded-xl border bg-white px-4 py-3 pr-10 text-sm font-medium transition-all outline-none focus:ring-2",
error && "border-danger/50 focus:ring-danger",
className,
)}
{...props}
@@ -48,11 +48,11 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted"
className="text-muted pointer-events-none absolute top-1/2 right-4 h-4 w-4 -translate-y-1/2"
/>
</div>
{error && (
<p id={errorId} role="alert" className="mt-1.5 ml-1 text-xs font-medium text-red-500">
<p id={errorId} role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
{error}
</p>
)}
+17 -8
View File
@@ -2,21 +2,30 @@ import type { AppointmentStatus } from "@/types/domain";
import { cn } from "@/lib/utils/cn";
const STATUS_CONFIG: Record<AppointmentStatus, { label: string; className: string }> = {
pending_approval: { label: "Pending Approval", className: "bg-amber-100 text-amber-700" },
confirmed: { label: "Confirmed", className: "bg-accent/10 text-accent" },
arrived: { label: "Arrived", className: "bg-accent/10 text-accent" },
in_progress: { label: "In Progress", className: "bg-blue-100 text-blue-700" },
pending_approval: {
label: "Pending Approval",
className: "bg-status-pending/10 text-status-pending",
},
confirmed: { label: "Confirmed", className: "bg-status-confirmed/10 text-status-confirmed" },
arrived: { label: "Arrived", className: "bg-status-confirmed/10 text-status-confirmed" },
in_progress: { label: "In Progress", className: "bg-status-progress/10 text-status-progress" },
completed: { label: "Completed", className: "bg-stone text-muted" },
cancelled: { label: "Cancelled", className: "bg-red-50 text-red-500" },
no_show: { label: "No-show", className: "bg-red-50 text-red-500" },
cancelled: { label: "Cancelled", className: "bg-status-cancelled/10 text-status-cancelled" },
no_show: { label: "No-show", className: "bg-status-cancelled/10 text-status-cancelled" },
};
export function StatusPill({ status, className }: { status: AppointmentStatus; className?: string }) {
export function StatusPill({
status,
className,
}: {
status: AppointmentStatus;
className?: string;
}) {
const config = STATUS_CONFIG[status];
return (
<span
className={cn(
"inline-block rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest",
"inline-block rounded-full px-3 py-1 text-[10px] font-bold tracking-widest uppercase",
config.className,
className,
)}
+5 -5
View File
@@ -20,7 +20,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
{label && (
<label
htmlFor={textareaId}
className="mb-2 ml-1 block text-[10px] font-bold uppercase tracking-widest text-muted"
className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase"
>
{label}
{required && <span aria-hidden="true"> *</span>}
@@ -34,19 +34,19 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
aria-invalid={!!error || undefined}
aria-describedby={cn(hintId, errorId) || undefined}
className={cn(
"w-full resize-none rounded-xl border border-line bg-white p-4 text-sm outline-none transition-all placeholder:text-muted focus:ring-2 focus:ring-ink",
error && "border-red-300 focus:ring-red-400",
"border-line placeholder:text-muted focus:ring-ink w-full resize-none rounded-xl border bg-white p-4 text-sm transition-all outline-none focus:ring-2",
error && "border-danger/50 focus:ring-danger",
className,
)}
{...props}
/>
{hint && !error && (
<p id={hintId} className="mt-1.5 ml-1 text-xs text-muted">
<p id={hintId} className="text-muted mt-1.5 ml-1 text-xs">
{hint}
</p>
)}
{error && (
<p id={errorId} role="alert" className="mt-1.5 ml-1 text-xs font-medium text-red-500">
<p id={errorId} role="alert" className="text-danger mt-1.5 ml-1 text-xs font-medium">
{error}
</p>
)}
+25 -8
View File
@@ -45,8 +45,18 @@ export function RegisterForm() {
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input label="First Name" placeholder="Alex" error={errors.firstName?.message} {...register("firstName")} />
<Input label="Last Name" placeholder="Chen" error={errors.lastName?.message} {...register("lastName")} />
<Input
label="First Name"
placeholder="Alex"
error={errors.firstName?.message}
{...register("firstName")}
/>
<Input
label="Last Name"
placeholder="Chen"
error={errors.lastName?.message}
{...register("lastName")}
/>
</div>
<Input
label="Email Address"
@@ -78,23 +88,30 @@ export function RegisterForm() {
/>
{formError && (
<p role="alert" className="text-sm font-medium text-red-500">
<p role="alert" className="text-danger text-sm font-medium">
{formError}
</p>
)}
<p className="px-1 text-[10px] leading-relaxed text-muted">
By creating an account, you agree to our <a href="#" className="underline">Terms of Service</a> and{" "}
<a href="#" className="underline">Privacy Policy</a>.
<p className="text-muted px-1 text-[10px] leading-relaxed">
By creating an account, you agree to our{" "}
<a href="#" className="underline">
Terms of Service
</a>{" "}
and{" "}
<a href="#" className="underline">
Privacy Policy
</a>
.
</p>
<Button type="submit" className="mt-4 w-full" isLoading={isSubmitting}>
Sign Up
</Button>
<p className="mt-10 text-center text-sm text-muted">
<p className="text-muted mt-10 text-center text-sm">
Already have an account?{" "}
<Link href="/sign-in" className="border-b border-ink font-bold text-ink">
<Link href="/sign-in" className="border-ink text-ink border-b font-bold">
Log in
</Link>
</p>
@@ -60,7 +60,7 @@ export function SignInForm() {
when that lands; no dead link to it here in the meantime. */}
{formError && (
<p role="alert" className="text-sm font-medium text-red-500">
<p role="alert" className="text-danger text-sm font-medium">
{formError}
</p>
)}
@@ -73,25 +73,25 @@ export function SignInForm() {
<div className="mt-10">
<div className="relative mb-8 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-line" />
<div className="border-line w-full border-t" />
</div>
<span className="relative bg-stone px-4 text-[10px] font-bold uppercase tracking-widest text-muted">
<span className="bg-stone text-muted relative px-4 text-[10px] font-bold tracking-widest uppercase">
Or continue with
</span>
</div>
<Button
type="button"
variant="secondary"
className="w-full normal-case tracking-normal"
className="w-full tracking-normal normal-case"
onClick={() => signIn("google", { callbackUrl })}
>
Continue with Google
</Button>
</div>
<p className="mt-10 text-center text-sm text-muted">
<p className="text-muted mt-10 text-center text-sm">
Don&apos;t have an account?{" "}
<Link href="/register" className="border-b-2 border-ink font-bold text-ink">
<Link href="/register" className="border-ink text-ink border-b-2 font-bold">
Create one
</Link>
</p>
@@ -8,7 +8,7 @@ export function SignOutButton() {
return (
<Button
variant="secondary"
className="w-full text-red-500 hover:bg-red-50"
className="text-danger hover:bg-danger/10 w-full"
onClick={() => signOut({ callbackUrl: "/" })}
>
<LogOut className="h-4 w-4" aria-hidden="true" />
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { PermissionDeniedError, assertCanAccessAppointment, canAccessAppointment } from "../authorization";
describe("assertCanAccessAppointment", () => {
it("allows a registered customer to access their own appointment", () => {
expect(() =>
assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_1"),
).not.toThrow();
});
it("denies a different signed-in user from accessing a registered customer's appointment", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toThrow(
PermissionDeniedError,
);
});
it("denies an unauthenticated caller from accessing a registered customer's appointment", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: "user_1" } }, undefined)).toThrow(
PermissionDeniedError,
);
});
it("allows access to a guest appointment (no linked user) regardless of session — the documented guest access model", () => {
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, undefined)).not.toThrow();
expect(() => assertCanAccessAppointment({ customer: { userId: null } }, "some_other_user")).not.toThrow();
});
});
describe("canAccessAppointment", () => {
it("mirrors assertCanAccessAppointment as a boolean", () => {
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_1")).toBe(true);
expect(canAccessAppointment({ customer: { userId: "user_1" } }, "user_2")).toBe(false);
expect(canAccessAppointment({ customer: { userId: null } }, undefined)).toBe(true);
});
});
@@ -0,0 +1,422 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { Weekday } from "@/generated/prisma/enums";
import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
import type { CalendarDate } from "@/lib/time/business-time";
import { cancelBooking, createBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import { listAppointments } from "@/services/scheduling/appointment.service";
import {
cleanupTestBusiness,
createTestBusiness,
createTestCustomer,
createTestGuestCustomer,
createTestStaff,
prisma,
} from "@/services/scheduling/__tests__/fixtures";
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
import { assertCanAccessAppointment, canAccessAppointment, PermissionDeniedError } from "@/features/booking/authorization";
import { getFirstFriendlyMessage } from "@/features/booking/error-messages";
/**
* Covers the customer-facing booking journeys added in Phase 4 — guest
* identity resolution, ownership enforcement, and the "book again"
* repricing rule — against a real database. Deliberately exercises the
* domain services directly (CustomerService, BookingService,
* AppointmentService, authorization.ts), the same layer Phase 3's suite
* tests: the Server Actions above them are thin orchestration (collect
* input, call these, map errors), verified instead by the Playwright
* journeys.
*/
function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
let date = addDays(todayIn(timezone), minDaysAhead);
for (let i = 0; i < 8; i++) {
if (weekdayOf(date) === target) return date;
date = addDays(date, 1);
}
throw new Error("unreachable");
}
describe("customer booking flows (integration)", () => {
let business: TestBusiness;
let monday: CalendarDate;
let guestProfileIds: string[];
beforeEach(async () => {
business = await createTestBusiness();
monday = nextWeekday(business.timezone, Weekday.MONDAY);
guestProfileIds = [];
});
afterEach(async () => {
if (guestProfileIds.length > 0) {
await prisma.customerProfile.deleteMany({ where: { id: { in: guestProfileIds } } }).catch(() => {});
}
await cleanupTestBusiness(business);
});
describe("guest booking", () => {
it("creates a new guest CustomerProfile and books under it", async () => {
const { staffId } = await createTestStaff(business);
const guest = { firstName: "Jordan", lastName: "Blake", email: "Jordan.Blake@Example.com", phone: "+44 7700 900111" };
const resolved = await resolveCustomerForBooking(prisma, { businessId: business.businessId, guest });
guestProfileIds.push(resolved.customerId);
expect(resolved.isNewProfile).toBe(true);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId: resolved.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.appointment.customer.isGuest).toBe(true);
expect(result.appointment.customer.userId).toBeNull();
// Stored lowercased even though the caller typed mixed case.
expect(result.appointment.customer.email).toBe("jordan.blake@example.com");
});
it("reuses an existing guest profile when email and phone both match, without overwriting its stored name", async () => {
const existing = await createTestGuestCustomer({ email: "sam.reed@example.com", phone: "+44 7700 900222" });
guestProfileIds.push(existing.customerId);
const resolved = await resolveCustomerForBooking(prisma, {
businessId: business.businessId,
// Different casing on the email, but same identifiers — should still match.
guest: { firstName: "Samuel", lastName: "Reed", email: "SAM.REED@example.com", phone: existing.phone },
});
expect(resolved.isNewProfile).toBe(false);
expect(resolved.customerId).toBe(existing.customerId);
const profile = await prisma.customerProfile.findUniqueOrThrow({ where: { id: existing.customerId } });
expect(profile.firstName).toBe("Guest"); // unchanged — reuse never overwrites on match
});
it("never merges two guests who share only one identifier — creates a separate profile instead", async () => {
const existing = await createTestGuestCustomer({ email: "shared.inbox@example.com", phone: "+44 7700 900333" });
guestProfileIds.push(existing.customerId);
// Same email (shared household inbox), different phone — must not be treated as the same person.
const resolved = await resolveCustomerForBooking(prisma, {
businessId: business.businessId,
guest: { firstName: "Different", lastName: "Person", email: existing.email, phone: "+44 7700 900444" },
});
guestProfileIds.push(resolved.customerId);
expect(resolved.isNewProfile).toBe(true);
expect(resolved.customerId).not.toBe(existing.customerId);
});
});
describe("authenticated booking", () => {
it("books under the caller's own profile and is idempotent across repeated calls", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId } = await createTestCustomer(business);
const resolvedAgain = await resolveCustomerForBooking(prisma, { businessId: business.businessId, userId });
expect(resolvedAgain.isNewProfile).toBe(false);
expect(resolvedAgain.customerId).toBe(customerId);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.appointment.customer.userId).toBe(userId);
});
});
describe("multi-service, specific-barber booking", () => {
it("books both selected services with one specific barber", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const result = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.appointment.staffId).toBe(staffId);
expect(result.appointment.services.map((s) => s.serviceId).sort()).toEqual(
[business.haircutServiceId, business.beardTrimServiceId].sort(),
);
});
});
describe("any-barber booking", () => {
it("auto-assigns whichever eligible barber is less busy that day", async () => {
const { staffId: busyStaffId } = await createTestStaff(business);
const { staffId: freeStaffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// Give the first barber an existing appointment that day so the second is strictly less busy.
const existing = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId: busyStaffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
expect(existing.success).toBe(true);
const anyBarber = await createBooking(prisma, {
businessId: business.businessId,
customerId,
// staffId omitted — "any available barber"
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(anyBarber.success).toBe(true);
if (!anyBarber.success) return;
expect(anyBarber.appointment.staffId).toBe(freeStaffId);
});
});
describe("slot becomes unavailable before confirmation", () => {
it("rejects the second confirmation with a customer-friendly message once someone else has taken the slot", async () => {
const { staffId } = await createTestStaff(business);
const { customerId: customerAId } = await createTestCustomer(business);
const { customerId: customerBId } = await createTestCustomer(business);
const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
// Customer B confirms first, while customer A's draft still points at the same slot.
const first = await createBooking(prisma, {
businessId: business.businessId,
customerId: customerBId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(first.success).toBe(true);
const second = await createBooking(prisma, {
businessId: business.businessId,
customerId: customerAId,
staffId,
serviceIds: [business.haircutServiceId],
startAt,
});
expect(second.success).toBe(false);
if (second.success) return;
expect(getFirstFriendlyMessage(second.validation)).toBe(
"That time was just booked by someone else — please choose another.",
);
});
});
describe("customer appointment retrieval", () => {
it("only returns the requesting customer's own appointments, filtered by status", async () => {
const { staffId } = await createTestStaff(business);
const { customerId: ownCustomerId } = await createTestCustomer(business);
const { customerId: otherCustomerId } = await createTestCustomer(business);
const confirmed = await createBooking(prisma, {
businessId: business.businessId,
customerId: ownCustomerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
expect(confirmed.success).toBe(true);
const othersAppointment = await createBooking(prisma, {
businessId: business.businessId,
customerId: otherCustomerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(othersAppointment.success).toBe(true);
const upcoming = await listAppointments(prisma, {
customerId: ownCustomerId,
statuses: ["PENDING", "CONFIRMED"],
});
expect(upcoming).toHaveLength(1);
expect(upcoming[0]!.customerId).toBe(ownCustomerId);
const cancelledList = await listAppointments(prisma, { customerId: ownCustomerId, statuses: ["CANCELLED"] });
expect(cancelledList).toHaveLength(0);
});
});
describe("cancellation", () => {
it("a guest can cancel their own booking without a cancelledByUserId", async () => {
const { staffId } = await createTestStaff(business);
const guest = await createTestGuestCustomer();
guestProfileIds.push(guest.customerId);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId: guest.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const result = await cancelBooking(prisma, { appointmentId: created.appointment.id, reason: "Can't make it" });
expect(result.success).toBe(true);
expect(result.appointment?.status).toBe("CANCELLED");
expect(result.appointment?.cancelledByUserId).toBeNull();
expect(result.appointment?.cancellationReason).toBe("Can't make it");
});
});
describe("rescheduling", () => {
it("supports moving to a different barber, not just a different time", async () => {
const { staffId: originalStaffId } = await createTestStaff(business);
const { staffId: newStaffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId: originalStaffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
const rescheduled = await rescheduleBooking(prisma, {
appointmentId: created.appointment.id,
staffId: newStaffId,
startAt: wallClockToInstant(monday, 14 * 60, business.timezone),
});
expect(rescheduled.success).toBe(true);
if (!rescheduled.success) return;
expect(rescheduled.appointment.staffId).toBe(newStaffId);
});
});
describe("book again", () => {
it("reprices at the current service price, never the old completed appointment's snapshot", async () => {
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const original = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId], // £20 at the time of fixtures.ts
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
if (!original.success) throw new Error("setup failed");
await prisma.appointment.update({ where: { id: original.appointment.id }, data: { status: "COMPLETED" } });
expect(Number(original.appointment.services[0]!.priceAtBooking)).toBe(20);
// Price goes up before the customer books again.
await prisma.service.update({ where: { id: business.haircutServiceId }, data: { price: 35 } });
const tuesday = addDays(monday, 1);
const rebooked = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(tuesday, 9 * 60, business.timezone),
});
expect(rebooked.success).toBe(true);
if (!rebooked.success) return;
// The new booking snapshots today's price, not the £20 from the completed appointment.
expect(Number(rebooked.appointment.services[0]!.priceAtBooking)).toBe(35);
});
});
describe("ownership enforcement", () => {
it("a registered customer's appointment is only accessible to that exact session user", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: ownerUserId } = await createTestCustomer(business);
const { userId: strangerUserId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
expect(() => assertCanAccessAppointment(created.appointment, ownerUserId)).not.toThrow();
expect(() => assertCanAccessAppointment(created.appointment, strangerUserId)).toThrow(PermissionDeniedError);
expect(() => assertCanAccessAppointment(created.appointment, undefined)).toThrow(PermissionDeniedError);
expect(canAccessAppointment(created.appointment, strangerUserId)).toBe(false);
});
it("does not treat the appointment id alone as permission — an unrelated session cannot claim it", async () => {
const { staffId } = await createTestStaff(business);
const { customerId, userId: ownerUserId } = await createTestCustomer(business);
const created = await createBooking(prisma, {
businessId: business.businessId,
customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
});
if (!created.success) throw new Error("setup failed");
expect(created.appointment.customer.userId).toBe(ownerUserId);
const randomOtherId = "some-other-user-id-that-is-not-the-owner";
expect(canAccessAppointment(created.appointment, randomOtherId)).toBe(false);
});
});
describe("guest data isolation", () => {
it("keeps two guests' profiles and appointments completely separate", async () => {
const { staffId } = await createTestStaff(business);
const guestA = await createTestGuestCustomer();
const guestB = await createTestGuestCustomer();
guestProfileIds.push(guestA.customerId, guestB.customerId);
const apptA = await createBooking(prisma, {
businessId: business.businessId,
customerId: guestA.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
});
const apptB = await createBooking(prisma, {
businessId: business.businessId,
customerId: guestB.customerId,
staffId,
serviceIds: [business.haircutServiceId],
startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
});
expect(apptA.success).toBe(true);
expect(apptB.success).toBe(true);
const guestAAppointments = await listAppointments(prisma, { customerId: guestA.customerId });
expect(guestAAppointments).toHaveLength(1);
expect(guestAAppointments[0]!.customerId).toBe(guestA.customerId);
// Resolving guest A's own details again must never return guest B's profile.
const resolvedA = await resolveCustomerForBooking(prisma, {
businessId: business.businessId,
guest: { firstName: "Guest", lastName: "Customer", email: guestA.email, phone: guestA.phone },
});
expect(resolvedA.customerId).toBe(guestA.customerId);
expect(resolvedA.customerId).not.toBe(guestB.customerId);
});
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { BOOKING_VALIDATION_ERROR_CODES } from "@/services/scheduling/types";
import { getFirstFriendlyMessage, getFriendlyMessage, getFriendlyValidationMessages } from "../error-messages";
describe("getFriendlyMessage", () => {
it("has a specific, non-empty message for every code the domain layer can produce", () => {
for (const code of BOOKING_VALIDATION_ERROR_CODES) {
const message = getFriendlyMessage(code);
expect(message.length).toBeGreaterThan(0);
}
});
it("never leaks the raw code into the message", () => {
for (const code of BOOKING_VALIDATION_ERROR_CODES) {
expect(getFriendlyMessage(code)).not.toContain(code);
}
});
it("gives SLOT_UNAVAILABLE a message that doesn't sound like an internal error", () => {
expect(getFriendlyMessage("SLOT_UNAVAILABLE").toLowerCase()).toContain("booked");
});
});
describe("getFriendlyValidationMessages", () => {
it("maps every issue in a validation result", () => {
const messages = getFriendlyValidationMessages({
valid: false,
issues: [
{ code: "STAFF_ON_BREAK", message: "internal" },
{ code: "BELOW_MIN_NOTICE", message: "internal" },
],
});
expect(messages).toHaveLength(2);
expect(messages[0]).toBe(getFriendlyMessage("STAFF_ON_BREAK"));
expect(messages[1]).toBe(getFriendlyMessage("BELOW_MIN_NOTICE"));
});
it("returns an empty array for a valid result", () => {
expect(getFriendlyValidationMessages({ valid: true, issues: [] })).toEqual([]);
});
});
describe("getFirstFriendlyMessage", () => {
it("returns the first issue's friendly message", () => {
const message = getFirstFriendlyMessage({
valid: false,
issues: [
{ code: "BUSINESS_CLOSED", message: "internal" },
{ code: "STAFF_ON_LEAVE", message: "internal" },
],
});
expect(message).toBe(getFriendlyMessage("BUSINESS_CLOSED"));
});
it("falls back gracefully when there are no issues", () => {
expect(getFirstFriendlyMessage({ valid: true, issues: [] })).toMatch(/went wrong/i);
});
});
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { clearDraft, readDraft, writeLastBookingReference } from "@/lib/booking-draft/cookie";
import { prisma } from "@/lib/db/prisma";
import { resolveCustomerForBooking } from "@/services/customer/customer.service";
import { createBooking } from "@/services/scheduling/booking.service";
import { getFirstFriendlyMessage } from "../error-messages";
export interface ConfirmBookingResult {
success: boolean;
error?: string;
}
/**
* The only action that actually creates an appointment. Resolves *which*
* customer this is via CustomerService (never trusts a client-supplied
* customerId — see docs/phase-4-summary.md, "Security"), then hands off
* entirely to BookingService.createBooking, which re-validates the slot
* inside a transaction before committing — this action does not decide
* whether the slot is actually available.
*/
export async function confirmBookingAction(businessId: string): Promise<ConfirmBookingResult> {
const draft = await readDraft(businessId);
if (draft.serviceIds.length === 0 || !draft.startAt) {
return { success: false, error: "Your booking details are incomplete — please start again." };
}
if (!draft.policyAcknowledged) {
return { success: false, error: "Please acknowledge the cancellation policy to continue." };
}
const session = await auth();
let customerId: string;
try {
if (session?.user?.id) {
const resolved = await resolveCustomerForBooking(prisma, { businessId, userId: session.user.id });
customerId = resolved.customerId;
} else {
if (!draft.guest) {
return { success: false, error: "Please provide your contact details first." };
}
const resolved = await resolveCustomerForBooking(prisma, { businessId, guest: draft.guest });
customerId = resolved.customerId;
}
} catch {
return { success: false, error: "We couldn't confirm your details. Please try again." };
}
const result = await createBooking(prisma, {
businessId,
customerId,
serviceIds: draft.serviceIds,
staffId: draft.staffId,
startAt: new Date(draft.startAt),
notes: draft.notes,
});
if (!result.success) {
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
await writeLastBookingReference(result.appointment.id);
await clearDraft();
redirect("/book/confirmation");
}
@@ -0,0 +1,87 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { prisma } from "@/lib/db/prisma";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
import { getFirstFriendlyMessage } from "../error-messages";
import { customerCancelSchema, customerRescheduleSchema } from "../schemas";
/**
* Every mutation here re-checks ownership against the current session
* before touching anything — never trusts that "the client only shows
* this button for your own appointments" is actually true, since the
* action can be called directly regardless of what the UI renders.
*/
export interface CustomerAppointmentActionResult {
success: boolean;
error?: string;
wasLateCancellation?: boolean;
}
export async function cancelCustomerAppointmentAction(input: unknown): Promise<CustomerAppointmentActionResult> {
const parsed = customerCancelSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Please try again." };
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
if (!appointment) return { success: false, error: "We couldn't find that appointment." };
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
throw error;
}
const result = await cancelBooking(prisma, {
appointmentId: parsed.data.appointmentId,
cancelledByUserId: session?.user?.id,
reason: parsed.data.reason,
});
if (!result.success) {
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
// Deliberately not revalidating here (unlike reschedule) — every affected
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
// extra freshness, and doing it immediately would drop the cancelled
// appointment out of the Upcoming list mid-render, unmounting this very
// dialog before the customer can read the late-cancellation notice or
// click "Done". The dialog's own onClose already calls router.refresh().
return { success: true, wasLateCancellation: result.wasLateCancellation };
}
export async function rescheduleCustomerAppointmentAction(input: unknown): Promise<CustomerAppointmentActionResult> {
const parsed = customerRescheduleSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
if (!appointment) return { success: false, error: "We couldn't find that appointment." };
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, error: error.message };
throw error;
}
const result = await rescheduleBooking(prisma, {
appointmentId: parsed.data.appointmentId,
staffId: parsed.data.staffId,
startAt: new Date(parsed.data.startAt),
});
if (!result.success) {
return { success: false, error: getFirstFriendlyMessage(result.validation) };
}
revalidatePath("/appointments");
revalidatePath(`/appointments/${parsed.data.appointmentId}`);
return { success: true };
}
+169
View File
@@ -0,0 +1,169 @@
"use server";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { clearDraft, readDraft, writeDraft } from "@/lib/booking-draft/cookie";
import { guestDetailsSchema } from "@/lib/booking-draft/schema";
import { prisma } from "@/lib/db/prisma";
import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { validateBookingSlot } from "@/services/scheduling/availability.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
import { getFirstFriendlyMessage } from "../error-messages";
import { saveBarberTimeStepSchema, saveDetailsStepSchema, saveServicesStepSchema, startRebookSchema } from "../schemas";
/**
* Step-by-step booking draft actions. Each one: parses input, calls a
* domain service where a real check is needed (never re-implements one),
* persists the relevant slice of the draft, and redirects to the next
* step. See src/lib/booking-draft for the storage mechanism.
*/
export interface StepActionResult {
success: boolean;
error?: string;
fieldErrors?: Record<string, string[] | undefined>;
}
export async function saveServicesStepAction(input: unknown): Promise<StepActionResult> {
const parsed = saveServicesStepSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Please select at least one service.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const draft = await readDraft(parsed.data.businessId);
const previousServiceIds = [...draft.serviceIds].sort().join(",");
const nextServiceIds = [...parsed.data.serviceIds].sort().join(",");
const servicesChanged = previousServiceIds !== nextServiceIds;
await writeDraft({
...draft,
businessId: parsed.data.businessId,
serviceIds: parsed.data.serviceIds,
// Changing the service selection can invalidate a previously chosen
// slot (different duration), so clear it — but only if it actually
// changed, so re-confirming the same services without edits doesn't
// lose progress for no reason.
...(servicesChanged ? { staffId: undefined, date: undefined, startAt: undefined } : {}),
});
redirect("/book/barber-time");
}
export async function saveBarberTimeStepAction(input: unknown): Promise<StepActionResult> {
const parsed = saveBarberTimeStepSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
const draft = await readDraft(parsed.data.businessId);
if (draft.serviceIds.length === 0) {
return { success: false, error: "Please select a service first." };
}
// Only re-validate here when a specific barber was chosen — "any
// available barber" is resolved to a specific one by BookingService
// itself at confirm time (see booking.service.ts), and re-implementing
// that resolution here just to pre-validate would duplicate its logic,
// which the brief explicitly prohibits.
if (parsed.data.staffId) {
const { validation } = await validateBookingSlot(prisma, {
businessId: parsed.data.businessId,
serviceIds: draft.serviceIds,
staffId: parsed.data.staffId,
startAt: new Date(parsed.data.startAt),
});
if (!validation.valid) {
return { success: false, error: getFirstFriendlyMessage(validation) };
}
}
await writeDraft({ ...draft, staffId: parsed.data.staffId, date: parsed.data.date, startAt: parsed.data.startAt });
redirect("/book/details");
}
export async function saveDetailsStepAction(input: unknown): Promise<StepActionResult> {
const parsed = saveDetailsStepSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "Please check the highlighted fields.", fieldErrors: parsed.error.flatten().fieldErrors };
}
const draft = await readDraft(parsed.data.businessId);
if (!draft.startAt) {
return { success: false, error: "Please choose a date and time first." };
}
if (!parsed.data.policyAcknowledged) {
return { success: false, error: "Please acknowledge the cancellation policy to continue." };
}
const session = await auth();
if (!session?.user) {
const guestParsed = guestDetailsSchema.safeParse({
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
email: parsed.data.email,
phone: parsed.data.phone,
});
if (!guestParsed.success) {
return {
success: false,
error: "Please fill in your contact details.",
fieldErrors: guestParsed.error.flatten().fieldErrors,
};
}
await writeDraft({
...draft,
guest: guestParsed.data,
notes: parsed.data.notes,
policyAcknowledged: true,
});
} else {
await writeDraft({
...draft,
guest: undefined,
notes: parsed.data.notes,
policyAcknowledged: true,
});
}
redirect("/book/review");
}
export async function startRebookAction(input: unknown): Promise<void> {
const parsed = startRebookSchema.safeParse(input);
if (!parsed.success) {
redirect("/appointments");
}
const appointment = await getAppointmentById(prisma, parsed.data.appointmentId);
if (!appointment) {
redirect("/appointments");
}
const session = await auth();
try {
assertCanAccessAppointment(appointment, session?.user?.id);
} catch (error) {
if (error instanceof PermissionDeniedError) {
redirect("/appointments");
}
throw error;
}
// Only preselect services that are still active — an inactive service
// can't be booked again, and the customer picks a fresh replacement
// instead of silently having it dropped with no explanation.
const activeServiceIds = appointment.services.filter((line) => line.service.isActive).map((line) => line.serviceId);
// Only preselect the original barber if they're still active and bookable online.
const staffId = appointment.staff.status === "ACTIVE" && appointment.staff.isBookableOnline ? appointment.staffId : undefined;
await clearDraft();
await writeDraft({
businessId: appointment.businessId,
serviceIds: activeServiceIds,
staffId,
policyAcknowledged: false,
rebookFromAppointmentId: appointment.id,
});
redirect("/book");
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Customer-facing ownership checks. Kept in the feature/action layer, not
* the domain services — AppointmentService/BookingService are also used
* by (future) staff/owner tooling, where an entirely different
* permission rule applies ("any staff member in this business"). This
* file is specifically "can THIS customer touch THIS appointment", so it
* lives next to the customer-facing actions that need it, per Phase 2's
* "permission enforcement happens again at the server-action layer"
* principle.
*/
export class PermissionDeniedError extends Error {
constructor(message = "You don't have permission to access this appointment.") {
super(message);
this.name = "PermissionDeniedError";
}
}
export interface OwnableAppointment {
customer: { userId: string | null };
}
/**
* Ownership rule:
* - A REGISTERED customer's appointment (customer.userId is set) always
* requires the requesting session to match that exact user. An
* appointment id alone is never sufficient — see the Phase 4 brief's
* explicit "Appointment IDs are not treated as permission".
* - A GUEST appointment (customer.userId is null) has no stronger
* credential available in this phase — no magic-link/OTP system was
* built (explicitly deferred, see docs/phase-4-summary.md). Access is
* by the appointment's own unguessable id, the same "order tracking"
* pattern many guest-checkout systems use. This is a deliberate,
* documented scope limitation, not an oversight.
*/
export function assertCanAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): void {
if (appointment.customer.userId && appointment.customer.userId !== sessionUserId) {
throw new PermissionDeniedError();
}
}
export function canAccessAppointment(appointment: OwnableAppointment, sessionUserId: string | undefined): boolean {
try {
assertCanAccessAppointment(appointment, sessionUserId);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { MonthCalendar } from "@/components/ui/month-calendar";
import { Select } from "@/components/ui/select";
import { EmptyState } from "@/components/feedback/empty-state";
import { CardSkeleton } from "@/components/feedback/skeleton";
import { CalendarX2 } from "lucide-react";
import {
getAvailableBarbersAction,
getAvailableDatesAction,
getAvailableTimeSlotsAction,
} from "../actions/availability";
interface Staff {
id: string;
name: string;
avatarUrl: string | null;
}
interface TimeSlot {
startMinutes: number;
startAt: string;
endAt: string;
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 {
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}`;
}
/**
* The interactive barber/date/time core, shared between the booking flow
* (/book/barber-time) and rescheduling (/appointments/[id]/reschedule) —
* per the Phase 4 brief's explicit instruction to reuse this selection UI
* rather than building it twice. All availability comes from the Phase 3
* AvailabilityService via the existing Server Actions; nothing here
* computes a slot itself.
*/
export function BarberDateTimePicker(props: {
businessId: string;
businessTimezone: string;
serviceIds: string[];
initialStaffId?: string;
initialDate?: string;
onContinue: (selection: {
staffId?: string;
date: string;
startAt: string;
}) => Promise<{ success: boolean; error?: string }>;
continueLabel?: string;
}) {
const {
businessId,
businessTimezone,
serviceIds,
initialStaffId,
initialDate,
onContinue,
continueLabel = "Continue",
} = props;
const [isPending, startTransition] = useTransition();
const [staffOptions, setStaffOptions] = useState<Staff[]>([]);
const [selectedStaffId, setSelectedStaffId] = useState<string>(initialStaffId ?? "any");
const [month, setMonth] = useState(() =>
initialDate ? new Date(`${initialDate}T00:00:00`) : new Date(),
);
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
initialDate ? new Date(`${initialDate}T00:00:00`) : undefined,
);
const [slots, setSlots] = useState<TimeSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState<TimeSlot | undefined>();
const [slotsPending, startSlotsTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getAvailableBarbersAction({ businessId, serviceIds }).then((res) => setStaffOptions(res.staff));
}, [businessId, serviceIds]);
useEffect(() => {
const from = new Date(month.getFullYear(), month.getMonth(), 1);
const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
getAvailableDatesAction({
businessId,
serviceIds,
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
fromDate: toYMD(from),
toDate: toYMD(to),
}).then((res) => setAvailableDates(new Set(res.dates)));
}, [businessId, serviceIds, month, selectedStaffId]);
useEffect(() => {
if (!selectedDate) return;
// The fetch (and every state update it produces) runs inside the
// transition callback, after an `await` — not synchronously at the
// top of the effect body — so this stays clear of the
// set-state-in-effect pitfall while still giving us a pending flag.
startSlotsTransition(async () => {
const res = await getAvailableTimeSlotsAction({
businessId,
serviceIds,
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
date: toYMD(selectedDate),
});
setSlots(res.slots);
setSelectedSlot(undefined);
});
}, [businessId, serviceIds, selectedDate, selectedStaffId]);
const effectiveSlots = !selectedDate ? EMPTY_SLOTS : slots;
const handleContinue = () => {
if (!selectedDate || !selectedSlot) return;
setError(null);
startTransition(async () => {
const result = await onContinue({
staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
date: toYMD(selectedDate),
startAt: selectedSlot.startAt,
});
if (!result.success) setError(result.error ?? "Please choose a different time.");
});
};
return (
<div className="pb-32">
<div className="mx-auto max-w-2xl space-y-6">
<section className="px-6">
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Barber</h2>
<Select
value={selectedStaffId}
onChange={(e) => {
setSelectedStaffId(e.target.value);
setSelectedDate(undefined);
}}
>
<option value="any">Anyone available</option>
{staffOptions.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</Select>
</section>
<section className="px-6">
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Date</h2>
<MonthCalendar
month={month}
onMonthChange={setMonth}
selectedDate={selectedDate}
onSelectDate={setSelectedDate}
isDateDisabled={(date) => !availableDates.has(toYMD(date))}
/>
</section>
{selectedDate && (
<section className="px-6">
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Time</h2>
{slotsPending ? (
<CardSkeleton />
) : effectiveSlots.length === 0 ? (
<EmptyState
icon={CalendarX2}
title="No times available"
description="Try a different date or barber."
/>
) : (
<div className="flex flex-wrap gap-2">
{effectiveSlots.map((slot) => (
<button
key={slot.startAt}
type="button"
onClick={() => setSelectedSlot(slot)}
aria-pressed={selectedSlot?.startAt === slot.startAt}
className={
selectedSlot?.startAt === slot.startAt
? "border-accent bg-accent/10 rounded-xl border-2 px-5 py-3 text-sm font-bold"
: "border-line hover:border-ink rounded-xl border bg-white px-5 py-3 text-sm font-medium"
}
>
{new Date(slot.startAt).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: businessTimezone,
})}
</button>
))}
</div>
)}
</section>
)}
</div>
<div className="border-line fixed inset-x-0 bottom-0 z-40 border-t bg-white/95 px-6 py-4 backdrop-blur">
<div className="mx-auto max-w-2xl">
{error && (
<p role="alert" className="text-danger mb-3 text-sm font-medium">
{error}
</p>
)}
<Button
className="w-full"
onClick={handleContinue}
isLoading={isPending}
disabled={!selectedSlot}
>
{continueLabel}
</Button>
</div>
</div>
</div>
);
}
const EMPTY_SLOTS: TimeSlot[] = [];
@@ -0,0 +1,21 @@
"use client";
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { startRebookAction } from "../actions/draft";
export function BookAgainButton({ appointmentId, className }: { appointmentId: string; className?: string }) {
const [isPending, startTransition] = useTransition();
return (
<Button
variant="secondary"
size="sm"
className={className}
isLoading={isPending}
onClick={() => startTransition(() => startRebookAction({ appointmentId }))}
>
Book Again
</Button>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";
import { Textarea } from "@/components/ui/textarea";
import { cancelCustomerAppointmentAction } from "../actions/customer-appointments";
export function CancelAppointmentDialog({ appointmentId }: { appointmentId: string }) {
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{ wasLateCancellation?: boolean } | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleConfirm = () => {
setError(null);
startTransition(async () => {
const res = await cancelCustomerAppointmentAction({
appointmentId,
reason: reason || undefined,
});
if (!res.success) {
setError(res.error ?? "Something went wrong. Please try again.");
return;
}
setResult({ wasLateCancellation: res.wasLateCancellation });
});
};
const handleClose = () => {
setOpen(false);
setResult(null);
setReason("");
setError(null);
if (result) router.refresh();
};
return (
<>
<Button variant="destructive" size="sm" onClick={() => setOpen(true)}>
Cancel
</Button>
<Modal
open={open}
onClose={handleClose}
title={result ? "Appointment Cancelled" : "Cancel Appointment?"}
>
{result ? (
<div className="space-y-4">
<p role="status" className="text-muted text-sm">
Your appointment has been cancelled. It won&apos;t be deleted from your history.
</p>
{result.wasLateCancellation && (
<p className="border-warning/30 bg-warning/10 text-warning-foreground rounded-lg border p-3 text-xs">
This was a late cancellation, outside the free-cancellation window. The business may
apply a late-cancellation policy.
</p>
)}
<Button className="w-full" onClick={handleClose}>
Done
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted text-sm">
This can&apos;t be undone. You can add an optional reason below.
</p>
<Textarea
placeholder="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && (
<p role="alert" className="text-danger text-sm font-medium">
{error}
</p>
)}
<div className="flex gap-3">
<Button variant="secondary" className="flex-1" onClick={handleClose}>
Keep it
</Button>
<Button
variant="destructive"
className="flex-1"
onClick={handleConfirm}
isLoading={isPending}
>
Cancel it
</Button>
</div>
</div>
)}
</Modal>
</>
);
}
@@ -0,0 +1,31 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { confirmBookingAction } from "../actions/confirm";
export function ConfirmBookingButton({ businessId }: { businessId: string }) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
setError(null);
startTransition(async () => {
const result = await confirmBookingAction(businessId);
if (!result.success) setError(result.error ?? "Something went wrong. Please try again.");
});
};
return (
<div>
{error && (
<p role="alert" className="text-danger mb-3 text-sm font-medium">
{error}
</p>
)}
<Button className="w-full" onClick={handleConfirm} isLoading={isPending}>
Confirm Booking
</Button>
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { saveDetailsStepAction } from "../actions/draft";
const guestFormSchema = z.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().min(1, "Phone number is required"),
notes: z.string().max(2000).optional(),
policyAcknowledged: z.literal(true, { error: "Please acknowledge the cancellation policy" }),
});
const authenticatedFormSchema = z.object({
notes: z.string().max(2000).optional(),
policyAcknowledged: z.literal(true, { error: "Please acknowledge the cancellation policy" }),
});
export function DetailsForm(props: {
businessId: string;
cancellationPolicy?: string;
isAuthenticated: boolean;
prefill?: { firstName: string; lastName: string; email: string; phone: string };
initialNotes?: string;
}) {
const { businessId, cancellationPolicy, isAuthenticated, prefill, initialNotes } = props;
const [serverError, setServerError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const schema = isAuthenticated ? authenticatedFormSchema : guestFormSchema;
const {
register,
handleSubmit,
setError,
formState: { errors },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} = useForm<any>({
resolver: zodResolver(schema),
defaultValues: {
firstName: prefill?.firstName ?? "",
lastName: prefill?.lastName ?? "",
email: prefill?.email ?? "",
phone: prefill?.phone ?? "",
notes: initialNotes ?? "",
policyAcknowledged: false,
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onSubmit = (data: any) => {
setServerError(null);
startTransition(async () => {
const result = await saveDetailsStepAction({ businessId, ...data });
if (!result.success) {
setServerError(result.error ?? "Please check the highlighted fields.");
if (result.fieldErrors) {
for (const [field, messages] of Object.entries(result.fieldErrors)) {
if (messages?.[0]) setError(field, { message: messages[0] });
}
}
}
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="mx-auto max-w-2xl space-y-6 px-6 py-6"
>
{!isAuthenticated && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="First Name"
error={errors.firstName?.message as string}
{...register("firstName")}
/>
<Input
label="Last Name"
error={errors.lastName?.message as string}
{...register("lastName")}
/>
</div>
<Input
label="Email Address"
type="email"
error={errors.email?.message as string}
{...register("email")}
/>
<Input
label="Phone Number"
type="tel"
error={errors.phone?.message as string}
{...register("phone")}
/>
</div>
)}
{isAuthenticated && prefill && (
<div className="rounded-jumbo border-line border bg-white p-5">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking as</p>
<p className="mt-1 text-sm font-bold">
{prefill.firstName} {prefill.lastName}
</p>
<p className="text-muted text-xs">
{prefill.email}
{prefill.phone ? ` · ${prefill.phone}` : ""}
</p>
</div>
)}
<Textarea
label="Booking Notes (optional)"
placeholder="e.g. skin sensitivity, specific style requests…"
error={errors.notes?.message as string}
{...register("notes")}
/>
{cancellationPolicy && (
<div className="border-line text-muted rounded-xl border bg-white p-4 text-xs">
{cancellationPolicy}
</div>
)}
<Checkbox label="I acknowledge the cancellation policy" {...register("policyAcknowledged")} />
{errors.policyAcknowledged && (
<p role="alert" className="text-danger -mt-4 text-xs font-medium">
{errors.policyAcknowledged.message as string}
</p>
)}
{serverError && (
<p role="alert" className="text-danger text-sm font-medium">
{serverError}
</p>
)}
<Button type="submit" className="w-full" isLoading={isPending}>
Continue to Review
</Button>
</form>
);
}
@@ -0,0 +1,147 @@
"use client";
import { Plus, Check } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { TopNav } from "@/components/navigation/top-nav";
import { saveServicesStepAction } from "../actions/draft";
import type { AvailableService } from "@/services/scheduling/availability.service";
export function ServiceSelector(props: {
businessId: string;
services: AvailableService[];
categoryNames: Record<string, string>;
categoryOrder: string[];
initialSelectedServiceIds: string[];
rebooking: boolean;
}) {
const {
businessId,
services,
categoryNames,
categoryOrder,
initialSelectedServiceIds,
rebooking,
} = props;
const [selectedIds, setSelectedIds] = useState<string[]>(initialSelectedServiceIds);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const grouped = useMemo(() => {
const byCategory = new Map<string, AvailableService[]>();
for (const service of services) {
const list = byCategory.get(service.categoryId) ?? [];
list.push(service);
byCategory.set(service.categoryId, list);
}
const orderedIds = [...categoryOrder, ...byCategory.keys()].filter(
(id, i, arr) => arr.indexOf(id) === i,
);
return orderedIds
.map((id) => ({
id,
name: categoryNames[id] ?? "Services",
services: byCategory.get(id) ?? [],
}))
.filter((g) => g.services.length > 0);
}, [services, categoryNames, categoryOrder]);
const selectedServices = services.filter((s) => selectedIds.includes(s.id));
const totalDuration = selectedServices.reduce((sum, s) => sum + s.durationMinutes, 0);
const totalPrice = selectedServices.reduce((sum, s) => sum + Number(s.price), 0);
const toggle = (id: string) => {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
};
const handleContinue = () => {
setError(null);
startTransition(async () => {
const result = await saveServicesStepAction({ businessId, serviceIds: selectedIds });
if (!result.success) setError(result.error ?? "Please select at least one service.");
});
};
return (
<div className="pb-32">
<TopNav title="Select Services" onBack={true} sticky className="bg-stone" />
{rebooking && (
<div className="border-accent/30 bg-accent/10 text-ink mx-6 mt-4 rounded-xl border p-3 text-xs">
Booking again — we&apos;ve preselected your previous services. Prices and availability are
current, not from your last visit.
</div>
)}
<main className="mx-auto max-w-2xl space-y-8 px-6 py-6">
{grouped.map((group) => (
<section key={group.id}>
<h2 className="mb-3 text-sm font-bold">{group.name}</h2>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
{group.services.map((service) => {
const selected = selectedIds.includes(service.id);
return (
<button
key={service.id}
type="button"
onClick={() => toggle(service.id)}
aria-pressed={selected}
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left"
>
<div className="flex-1">
<h3 className="text-sm font-medium">{service.name}</h3>
<p className="text-muted mt-1 text-xs">{service.durationMinutes}m</p>
</div>
<span className="font-bold">£{Number(service.price).toFixed(2)}</span>
<span
className={
selected
? "bg-accent flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white"
: "border-line text-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-full border"
}
>
{selected ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Plus className="h-4 w-4" aria-hidden="true" />
)}
</span>
</button>
);
})}
</div>
</section>
))}
{grouped.length === 0 && (
<p className="text-muted text-sm">No services are available online right now.</p>
)}
</main>
<div className="border-line fixed inset-x-0 bottom-0 z-40 border-t bg-white/95 px-6 py-4 backdrop-blur">
<div className="mx-auto max-w-2xl">
{error && (
<p role="alert" className="text-danger mb-3 text-sm font-medium">
{error}
</p>
)}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-muted text-xs font-bold tracking-widest uppercase">
{selectedServices.length} selected · {totalDuration}m
</p>
<p className="text-xl font-semibold">£{totalPrice.toFixed(2)}</p>
</div>
<Button
onClick={handleContinue}
isLoading={isPending}
disabled={selectedIds.length === 0}
>
Continue
</Button>
</div>
</div>
</div>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { BookingValidationErrorCode, ValidationResult } from "@/services/scheduling/types";
/**
* Maps the typed error codes the Phase 3 services already return into
* customer-friendly copy. The only place raw codes get translated —
* every booking-flow page imports from here rather than inlining its own
* message strings, so the wording only needs to change in one place.
*/
const FRIENDLY_MESSAGES: Record<BookingValidationErrorCode, string> = {
INVALID_INPUT: "Something about that wasn't quite right. Please try again.",
NO_SERVICES_SELECTED: "Please select at least one service.",
SERVICES_SPAN_MULTIPLE_BUSINESSES: "One or more selected services aren't available here.",
STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.",
BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.",
STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.",
STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.",
STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.",
SLOT_UNAVAILABLE: "That time was just booked by someone else — please choose another.",
INSUFFICIENT_DURATION:
"There isn't enough time left that day for the selected services — try an earlier time or fewer services.",
BELOW_MIN_NOTICE: "This time is too soon — please choose a later time.",
BEYOND_MAX_ADVANCE: "This date is too far ahead to book yet — please choose a nearer date.",
BUSINESS_DAILY_LIMIT_REACHED: "This business is fully booked that day — please choose another date.",
STAFF_DAILY_LIMIT_REACHED: "This barber is fully booked that day — please choose another barber or date.",
APPOINTMENT_NOT_FOUND: "We couldn't find that appointment.",
INVALID_STATUS_TRANSITION: "This appointment can no longer be changed.",
STAFF_NOT_FOUND: "We couldn't find that barber.",
CUSTOMER_NOT_FOUND: "We couldn't find your customer details.",
BUSINESS_NOT_FOUND: "We couldn't find this business.",
};
const FALLBACK_MESSAGE = "Something went wrong with this booking. Please try again.";
export function getFriendlyMessage(code: BookingValidationErrorCode): string {
return FRIENDLY_MESSAGES[code] ?? FALLBACK_MESSAGE;
}
export function getFriendlyValidationMessages(validation: ValidationResult): string[] {
if (validation.issues.length === 0) return [];
return validation.issues.map((issue) => getFriendlyMessage(issue.code));
}
export function getFirstFriendlyMessage(validation: ValidationResult): string {
const [first] = validation.issues;
return first ? getFriendlyMessage(first.code) : FALLBACK_MESSAGE;
}
+40 -1
View File
@@ -50,7 +50,7 @@ export const rescheduleBookingSchema = z.object({
export const cancelBookingSchema = z.object({
appointmentId: z.string(),
cancelledByUserId: z.string(),
cancelledByUserId: z.string().optional(),
reason: z.string().optional(),
});
@@ -58,3 +58,42 @@ export const updateBookingNotesSchema = z.object({
appointmentId: z.string(),
notes: z.string().max(2000),
});
// ── Customer booking-flow draft steps ────────────────────────────────────
export const saveServicesStepSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).min(1, "Select at least one service"),
});
export const saveBarberTimeStepSchema = z.object({
businessId: z.string(),
staffId: z.string().optional(),
date: z.string(),
startAt: z.iso.datetime(),
});
export const saveDetailsStepSchema = z.object({
businessId: z.string(),
firstName: z.string().trim().min(1, "First name is required").optional(),
lastName: z.string().trim().min(1, "Last name is required").optional(),
email: z.email("Enter a valid email address").optional(),
phone: z.string().trim().min(1, "Phone number is required").optional(),
notes: z.string().max(2000).optional(),
policyAcknowledged: z.boolean(),
});
export const startRebookSchema = z.object({
appointmentId: z.string(),
});
export const customerCancelSchema = z.object({
appointmentId: z.string(),
reason: z.string().max(500).optional(),
});
export const customerRescheduleSchema = z.object({
appointmentId: z.string(),
staffId: z.string().optional(),
startAt: z.iso.datetime(),
});
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it } from "vitest";
import { sign, verify } from "../sign";
describe("sign / verify", () => {
beforeEach(() => {
process.env.AUTH_SECRET = "test-secret-not-for-production-use-only";
});
it("round-trips a payload", () => {
const token = sign('{"serviceIds":["svc_1"]}');
expect(verify(token)).toBe('{"serviceIds":["svc_1"]}');
});
it("rejects a tampered payload", () => {
const token = sign('{"serviceIds":["svc_1"]}');
const [, mac] = token.split(".");
const tamperedPayload = Buffer.from('{"serviceIds":["svc_2"]}', "utf8").toString("base64url");
expect(verify(`${tamperedPayload}.${mac}`)).toBeNull();
});
it("rejects a tampered signature", () => {
const token = sign('{"a":1}');
const [payload] = token.split(".");
expect(verify(`${payload}.not-a-real-signature`)).toBeNull();
});
it("rejects malformed tokens without throwing", () => {
expect(verify("")).toBeNull();
expect(verify("no-separator")).toBeNull();
expect(verify("...")).toBeNull();
expect(verify("!!!not-base64!!!.mac")).toBeNull();
});
it("rejects a token signed with a different secret", () => {
const token = sign('{"a":1}');
process.env.AUTH_SECRET = "a-completely-different-secret-value";
expect(verify(token)).toBeNull();
});
it("throws clearly if AUTH_SECRET is missing rather than signing with an empty key", () => {
delete process.env.AUTH_SECRET;
expect(() => sign("payload")).toThrow(/AUTH_SECRET/);
});
});
+82
View File
@@ -0,0 +1,82 @@
import "server-only";
import { cookies } from "next/headers";
import { bookingDraftSchema, emptyDraft } from "./schema";
import type { BookingDraft } from "./schema";
import { sign, verify } from "./sign";
/**
* Server-only I/O around the signed booking-draft cookie. Callable from
* Server Components (read) and Server Actions/Route Handlers (read +
* write) — `next/headers`'s `cookies()` enforces that boundary itself.
*/
const COOKIE_NAME = "nc_booking_draft";
const MAX_AGE_SECONDS = 60 * 60; // 1 hour — long enough to finish a booking, short enough not to linger
export async function readDraft(businessId: string): Promise<BookingDraft> {
const store = await cookies();
const raw = store.get(COOKIE_NAME)?.value;
if (!raw) return emptyDraft(businessId);
const payload = verify(raw);
if (!payload) return emptyDraft(businessId);
try {
const parsed = bookingDraftSchema.safeParse(JSON.parse(payload));
if (!parsed.success || parsed.data.businessId !== businessId) return emptyDraft(businessId);
return parsed.data;
} catch {
return emptyDraft(businessId);
}
}
export async function writeDraft(draft: BookingDraft): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify(draft));
store.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: MAX_AGE_SECONDS,
});
}
export async function clearDraft(): Promise<void> {
const store = await cookies();
store.delete(COOKIE_NAME);
}
// ── Post-booking confirmation reference ─────────────────────────────────
// Deliberately a separate, tiny cookie rather than a URL query param, so
// a bookmarked/shared confirmation URL never carries even a non-sensitive
// appointment id — see docs/phase-4-summary.md.
const CONFIRMATION_COOKIE_NAME = "nc_last_booking";
const CONFIRMATION_MAX_AGE_SECONDS = 60 * 30;
export async function writeLastBookingReference(appointmentId: string): Promise<void> {
const store = await cookies();
const token = sign(JSON.stringify({ appointmentId }));
store.set(CONFIRMATION_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: CONFIRMATION_MAX_AGE_SECONDS,
});
}
export async function readLastBookingReference(): Promise<string | null> {
const store = await cookies();
const raw = store.get(CONFIRMATION_COOKIE_NAME)?.value;
if (!raw) return null;
const payload = verify(raw);
if (!payload) return null;
try {
const parsed = JSON.parse(payload) as { appointmentId?: unknown };
return typeof parsed.appointmentId === "string" ? parsed.appointmentId : null;
} catch {
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { z } from "zod";
/**
* The booking-in-progress draft. Lives in a signed, httpOnly cookie (see
* cookie.ts) rather than only in React state, so it survives a page
* refresh, browser back/forward, and a sign-in that happens mid-flow —
* see docs/phase-4-summary.md for the full reasoning.
*/
export const guestDetailsSchema = z.object({
firstName: z.string().trim().min(1, "First name is required"),
lastName: z.string().trim().min(1, "Last name is required"),
email: z.email("Enter a valid email address"),
phone: z.string().trim().min(1, "Phone number is required"),
});
export type GuestDetails = z.infer<typeof guestDetailsSchema>;
export const bookingDraftSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).default([]),
/** undefined = "any available barber". */
staffId: z.string().optional(),
date: z.string().optional(), // CalendarDate, "YYYY-MM-DD"
startAt: z.iso.datetime().optional(),
notes: z.string().max(2000).optional(),
guest: guestDetailsSchema.optional(),
policyAcknowledged: z.boolean().default(false),
/** Set by "Book again" so the services step can show which appointment this draft originated from — display only, never trusted for pricing (see BookingService, which always re-reads current Service prices). */
rebookFromAppointmentId: z.string().optional(),
});
export type BookingDraft = z.infer<typeof bookingDraftSchema>;
export function emptyDraft(businessId: string): BookingDraft {
return {
businessId,
serviceIds: [],
policyAcknowledged: false,
};
}
+62
View File
@@ -0,0 +1,62 @@
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Tamper-evident (not encrypted) signing for the booking-draft cookie —
* an HMAC-SHA256 over the JSON payload using Node's built-in `crypto`,
* not a hand-rolled cipher. Signing rather than encrypting is a
* deliberate choice: the draft holds data the customer themselves just
* typed into a form on this domain (their own name/email/phone, plus
* non-sensitive selections), so the realistic threat is tampering
* (editing the cookie to book at a different price or under a different
* identity), not disclosure to the customer's own browser. httpOnly +
* Secure + SameSite=Lax (set in cookie.ts) cover disclosure to anyone
* else. Kept as a separate module from cookie.ts so the crypto itself is
* unit-testable without Next's `cookies()` API.
*/
const CONTEXT = "nextchair-booking-draft-v1";
function getSigningKey(): Buffer {
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error("AUTH_SECRET must be set to sign the booking draft cookie.");
}
// Derive a purpose-specific key from the shared app secret rather than
// reusing it directly, so this cookie's signing key and NextAuth's JWT
// key are cryptographically independent even though they start from
// the same secret.
return createHmac("sha256", secret).update(CONTEXT).digest();
}
/** payload.signature, both base64url — a compact, cookie-safe format. */
export function sign(payload: string): string {
const mac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const encodedPayload = Buffer.from(payload, "utf8").toString("base64url");
return `${encodedPayload}.${mac}`;
}
/** Returns the original payload if the signature is valid, otherwise null. Never throws on malformed input. */
export function verify(token: string): string | null {
const separatorIndex = token.indexOf(".");
if (separatorIndex === -1) return null;
const encodedPayload = token.slice(0, separatorIndex);
const mac = token.slice(separatorIndex + 1);
if (!encodedPayload || !mac) return null;
let payload: string;
try {
payload = Buffer.from(encodedPayload, "base64url").toString("utf8");
} catch {
return null;
}
const expectedMac = createHmac("sha256", getSigningKey()).update(payload).digest("base64url");
const macBuffer = Buffer.from(mac);
const expectedBuffer = Buffer.from(expectedMac);
if (macBuffer.length !== expectedBuffer.length) return null;
if (!timingSafeEqual(macBuffer, expectedBuffer)) return null;
return payload;
}
+21
View File
@@ -0,0 +1,21 @@
import { cache } from "react";
import { prisma } from "@/lib/db/prisma";
/**
* v1 is single-business (see docs/implementation-plan.md §11) — every
* page that needs "the business" fetches it through here rather than
* hardcoding a slug or re-querying independently. Wrapped in React's
* `cache()` so multiple Server Components in the same request (a layout
* and its page, for instance) share one query instead of issuing it
* once each.
*/
export const getPrimaryBusiness = cache(async () => {
return prisma.business.findFirstOrThrow({
include: {
locations: true,
hours: true,
settings: true,
bookingRule: true,
},
});
});
+45
View File
@@ -0,0 +1,45 @@
import { minutesFromMidnight, todayIn, weekdayOf } from "@/lib/time/business-time";
import { Weekday } from "@/generated/prisma/enums";
export interface OpeningStatus {
isOpen: boolean;
label: string;
}
interface DayHoursRow {
dayOfWeek: Weekday;
isClosed: boolean;
opensAtMinutes: number | null;
closesAtMinutes: number | null;
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/**
* Purely presentational (read-only "open now?" label for the home page)
* — not part of the booking engine's own open/closed check, which
* AvailabilityService already performs independently as a real booking
* rule. This is display-only, so it lives here rather than in
* services/scheduling.
*/
export function computeOpeningStatus(hours: DayHoursRow[], timezone: string): OpeningStatus {
const today = todayIn(timezone);
const weekday = weekdayOf(today);
const todayHours = hours.find((h) => h.dayOfWeek === weekday);
if (!todayHours || todayHours.isClosed || todayHours.opensAtMinutes === null || todayHours.closesAtMinutes === null) {
return { isOpen: false, label: "Closed today" };
}
const nowMinutes = minutesFromMidnight(new Date(), timezone);
const isOpen = nowMinutes >= todayHours.opensAtMinutes && nowMinutes < todayHours.closesAtMinutes;
return {
isOpen,
label: isOpen ? `Open until ${formatMinutes(todayHours.closesAtMinutes)}` : `Closed · Opens ${formatMinutes(todayHours.opensAtMinutes)}`,
};
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { generateAppointmentIcs } from "../ics";
const BASE_INPUT = {
appointmentId: "appt_123",
businessName: "Blake Heritage Barbershop",
businessAddress: "79 Joel Street, HA6 1LW",
staffName: "Marcus Reed",
serviceNames: ["Haircut & Style"],
startAt: new Date("2026-08-10T09:00:00.000Z"),
endAt: new Date("2026-08-10T09:30:00.000Z"),
};
describe("generateAppointmentIcs", () => {
it("produces a well-formed VCALENDAR/VEVENT block", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/);
expect(ics).toContain("BEGIN:VEVENT\r\n");
expect(ics).toContain("END:VEVENT\r\n");
expect(ics.trimEnd()).toMatch(/END:VCALENDAR$/);
});
it("formats DTSTART/DTEND as UTC instants regardless of local time zone", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).toContain("DTSTART:20260810T090000Z");
expect(ics).toContain("DTEND:20260810T093000Z");
});
it("includes a UID derived from the appointment id, stable across re-generation", () => {
const first = generateAppointmentIcs(BASE_INPUT);
const second = generateAppointmentIcs(BASE_INPUT);
const uidPattern = /UID:appt_123@nextchair/;
expect(first).toMatch(uidPattern);
expect(second).toMatch(uidPattern);
});
it("combines multiple service names in the summary and description", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, serviceNames: ["Haircut & Style", "Beard Trim"] });
expect(ics).toContain("SUMMARY:Haircut & Style + Beard Trim at Blake Heritage Barbershop");
expect(ics).toContain("Services: Haircut & Style\\, Beard Trim");
});
it("escapes commas, semicolons, and backslashes per RFC 5545", () => {
const ics = generateAppointmentIcs({
...BASE_INPUT,
businessName: "Sam's Cuts; Trims, & More\\Co",
});
// Escaped backslash first, then semicolon, then comma — order matters so
// the escaping backslashes themselves don't get re-escaped.
expect(ics).toContain("Sam's Cuts\\; Trims\\, & More\\\\Co");
});
it("escapes newlines in notes as literal \\n rather than breaking the content line", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: "Line one\nLine two" });
expect(ics).toContain("Line one\\nLine two");
// No raw, unescaped newline should appear inside the DESCRIPTION value itself.
const descriptionLine = ics.split("\r\n").find((l) => l.startsWith("DESCRIPTION"));
expect(descriptionLine).toBeDefined();
});
it("omits the Notes line entirely when there are no notes", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics).not.toContain("Notes:");
});
it("falls back to just the business name when no address is given", () => {
const ics = generateAppointmentIcs({ ...BASE_INPUT, businessAddress: undefined });
expect(ics).toContain("LOCATION:Blake Heritage Barbershop");
});
it("folds content lines longer than 75 octets per RFC 5545", () => {
const longNotes = "N".repeat(200);
const ics = generateAppointmentIcs({ ...BASE_INPUT, notes: longNotes });
const rawLines = ics.split("\r\n");
// No single physical line (before folding continuation) should exceed 75 octets.
for (const line of rawLines) {
expect(line.length).toBeLessThanOrEqual(75);
}
// Continuation lines start with a single space, per the spec.
const descriptionStart = rawLines.findIndex((l) => l.startsWith("DESCRIPTION"));
expect(rawLines[descriptionStart + 1]?.startsWith(" ")).toBe(true);
});
it("uses CRLF line endings throughout", () => {
const ics = generateAppointmentIcs(BASE_INPUT);
expect(ics.includes("\r\n")).toBe(true);
// Every bare \n should be immediately preceded by \r.
for (let i = 0; i < ics.length; i++) {
if (ics[i] === "\n") {
expect(ics[i - 1]).toBe("\r");
}
}
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* RFC 5545 (.ics) generation — provider-independent, works with every
* calendar app without any Google/Apple API access, per the Phase 4
* brief ("Do not require Google Calendar API access for the basic
* feature"). Pure string formatting, no I/O — the caller (a route
* handler) supplies already-validated, already-loaded data.
*
* Time zone handling: DTSTART/DTEND are emitted as UTC instants
* (`YYYYMMDDTHHMMSSZ`). This is the simplest *correct* approach — every
* calendar app converts a UTC-suffixed time to the viewer's own local
* time zone automatically, so there's no VTIMEZONE block to get wrong
* and no ambiguity around DST for whoever's viewing the event.
*/
export interface AppointmentIcsInput {
appointmentId: string;
businessName: string;
businessAddress?: string;
staffName: string;
serviceNames: string[];
startAt: Date;
endAt: Date;
notes?: string;
}
function pad(n: number, width = 2): string {
return String(n).padStart(width, "0");
}
function formatUtc(date: Date): string {
return (
`${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
`T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
);
}
/** Escapes text per RFC 5545 §3.3.11 — backslash, semicolon, comma, then newlines. Order matters: backslash must be escaped first, or the escaping backslashes we add for the other characters would themselves get re-escaped. */
function escapeText(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r\n|\r|\n/g, "\\n");
}
/** Folds a content line at 75 octets per RFC 5545 §3.1, continuation lines prefixed with a single space. */
function foldLine(line: string): string {
const CRLF_FOLD = "\r\n ";
if (line.length <= 75) return line;
const chunks: string[] = [];
let rest = line;
let first = true;
while (rest.length > 0) {
const limit = first ? 75 : 74; // continuation lines lose one column to the leading space
chunks.push(rest.slice(0, limit));
rest = rest.slice(limit);
first = false;
}
return chunks.join(CRLF_FOLD);
}
export function generateAppointmentIcs(input: AppointmentIcsInput): string {
const summary = escapeText(`${input.serviceNames.join(" + ")} at ${input.businessName}`);
const descriptionParts = [
`Barber: ${input.staffName}`,
`Services: ${input.serviceNames.join(", ")}`,
`Booking reference: ${input.appointmentId}`,
];
if (input.notes) descriptionParts.push(`Notes: ${input.notes}`);
const description = escapeText(descriptionParts.join("\n"));
const location = input.businessAddress ? escapeText(`${input.businessName}, ${input.businessAddress}`) : escapeText(input.businessName);
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//NextChair//Booking//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
`UID:${input.appointmentId}@nextchair`,
`DTSTAMP:${formatUtc(new Date())}`,
`DTSTART:${formatUtc(input.startAt)}`,
`DTEND:${formatUtc(input.endAt)}`,
`SUMMARY:${summary}`,
`DESCRIPTION:${description}`,
`LOCATION:${location}`,
"END:VEVENT",
"END:VCALENDAR",
];
return lines.map(foldLine).join("\r\n") + "\r\n";
}
+7 -1
View File
@@ -2,7 +2,13 @@ import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import { authConfig } from "@/auth.config";
const PROTECTED_PREFIXES = ["/home", "/appointments", "/settings", "/staff", "/owner"];
// /home, /appointments and /settings are intentionally NOT hard-gated here
// as of Phase 4 — Home must work for guests (public business browsing +
// booking), and /appointments and /settings each show their own friendly
// in-context sign-in prompt for a guest rather than a silent redirect with
// no explanation. Only /staff and /owner remain hard-gated: those are
// permission-based tooling with no meaningful "guest" experience at all.
const PROTECTED_PREFIXES = ["/staff", "/owner"];
// Lightweight instance built from the DB-free config — see auth.config.ts.
// Next.js 16 renamed this file convention from `middleware` to `proxy`
+120
View File
@@ -0,0 +1,120 @@
import type { PrismaClient } from "@/generated/prisma/client";
import type { GuestDetails } from "@/lib/booking-draft/schema";
/**
* New in Phase 4 — anticipated by the Phase 3 brief's list of future
* domain services ("LoyaltyService, NotificationService, CustomerService,
* StaffService..."). Owns exactly one thing: resolving *which*
* CustomerProfile a booking belongs to. Nothing about availability,
* scheduling, or booking rules — those stay in the Phase 3 services this
* one is called alongside, never duplicated.
*
* ── Guest matching rules (documented per the Phase 4 brief) ──
*
* 1. Authenticated caller → always their own CustomerProfile (via
* User.customerProfile), created on first booking if it doesn't exist
* yet. No email/phone matching happens at all for an authenticated
* caller — their identity is already established by the session, so
* there is no ambiguity to resolve.
*
* 2. Guest caller (no session) → search for an existing REUSABLE guest
* profile: `isGuest = true` AND `userId IS NULL` AND email matches
* (case-insensitive) AND phone matches exactly. Both identifiers must
* match — a single weak identifier (email alone, phone alone) is
* never sufficient, so two different guests who happen to share an
* email (a shared household inbox) or a phone number never collide
* into the same profile from a partial match.
*
* 3. A guest is NEVER matched against a profile that has `userId` set
* (i.e. a registered customer's profile) — that would attach a guest
* booking to a real account without any verification, which the brief
* explicitly forbids. Registered-customer profiles are simply outside
* the guest search space.
*
* 4. No match found → create a new guest CustomerProfile
* (`isGuest: true`, `userId: null`). Existing-profile fields are never
* overwritten on reuse (case 2) — if a returning guest typoed their
* name differently this time, that's a data-quality nuance for a
* later phase, not something this function silently "fixes" by
* guessing which version is correct.
*
* Full account claiming (a guest later creating an account and having
* their past guest bookings attached to it) is explicitly out of scope
* for this phase — see docs/phase-4-summary.md.
*/
export interface ResolveCustomerParams {
businessId: string;
/** Present for an authenticated caller. */
userId?: string;
/** Present for a guest caller. Ignored if `userId` is provided. */
guest?: GuestDetails;
}
export interface ResolvedCustomer {
customerId: string;
isNewProfile: boolean;
}
export async function resolveCustomerForBooking(
prisma: PrismaClient,
params: ResolveCustomerParams,
): Promise<ResolvedCustomer> {
if (params.userId) {
return resolveAuthenticatedCustomer(prisma, params.userId);
}
if (params.guest) {
return resolveGuestCustomer(prisma, params.guest);
}
throw new Error("resolveCustomerForBooking: either userId or guest details are required.");
}
async function resolveAuthenticatedCustomer(prisma: PrismaClient, userId: string): Promise<ResolvedCustomer> {
const existing = await prisma.customerProfile.findUnique({ where: { userId } });
if (existing) return { customerId: existing.id, isNewProfile: false };
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const [firstName, ...rest] = (user.name ?? "").trim().split(/\s+/).filter(Boolean);
const created = await prisma.customerProfile.create({
data: {
userId,
firstName: firstName || "Customer",
lastName: rest.join(" ") || "",
email: user.email ?? "",
phone: user.phone ?? undefined,
isGuest: false,
},
});
return { customerId: created.id, isNewProfile: true };
}
async function resolveGuestCustomer(prisma: PrismaClient, guest: GuestDetails): Promise<ResolvedCustomer> {
// Case-insensitive email matching without Prisma's `mode: "insensitive"`
// filter — that option is Postgres/MongoDB-only, not MySQL, which would
// break this query silently (or fail) after a provider swap. Normalizing
// case in application code instead keeps the comparison a plain equality
// check, portable across both.
const normalizedEmail = guest.email.trim().toLowerCase();
const existing = await prisma.customerProfile.findFirst({
where: {
userId: null,
isGuest: true,
email: normalizedEmail,
phone: guest.phone,
},
});
if (existing) return { customerId: existing.id, isNewProfile: false };
const created = await prisma.customerProfile.create({
data: {
firstName: guest.firstName,
lastName: guest.lastName,
email: normalizedEmail,
phone: guest.phone,
isGuest: true,
},
});
return { customerId: created.id, isNewProfile: true };
}
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "vitest";
import { createDemoReviewsProvider, getBusinessReviews, setReviewsProvider } from "../reviews.service";
import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service";
describe("reviews.service", () => {
afterEach(() => {
setReviewsProvider(createDemoReviewsProvider());
});
it("returns demo data labelled as such by default", async () => {
const result = await getBusinessReviews("any-business-id");
expect(result.isDemoData).toBe(true);
expect(result.reviews.length).toBeGreaterThan(0);
expect(result.averageRating).toBeGreaterThanOrEqual(1);
expect(result.averageRating).toBeLessThanOrEqual(5);
expect(result.totalReviews).toBeGreaterThan(0);
});
it("every demo review has a rating within 1–5", async () => {
const result = await getBusinessReviews("any-business-id");
for (const review of result.reviews) {
expect(review.rating).toBeGreaterThanOrEqual(1);
expect(review.rating).toBeLessThanOrEqual(5);
}
});
it("can be swapped for a different provider without callers changing", async () => {
const fakeSummary: BusinessReviewSummary = {
averageRating: 4.2,
totalReviews: 7,
reviews: [],
isDemoData: false,
};
const fakeProvider: ReviewsProvider = {
async getBusinessReviews() {
return fakeSummary;
},
};
setReviewsProvider(fakeProvider);
const result = await getBusinessReviews("any-business-id");
expect(result).toEqual(fakeSummary);
});
});
+76
View File
@@ -0,0 +1,76 @@
/**
* Reviews behind a provider abstraction, per the Phase 4 brief — a real
* Google Places/Business Profile integration can implement this same
* interface later without any caller (Customer Home, etc.) changing.
* Backed by clearly-labelled demo data for now; no schema/DB involvement,
* per the Phase 1 plan's recommendation to treat reviews as an external,
* cached read rather than a first-party entity.
*/
export interface ReviewItem {
id: string;
authorName: string;
rating: number; // 1–5
text: string;
relativeDate: string; // pre-formatted, e.g. "2 weeks ago" — a real provider would supply this directly
}
export interface BusinessReviewSummary {
averageRating: number;
totalReviews: number;
reviews: ReviewItem[];
/** True for the demo provider — callers should visibly label the data as such (see Customer Home). */
isDemoData: boolean;
}
export interface ReviewsProvider {
getBusinessReviews(businessId: string): Promise<BusinessReviewSummary>;
}
const DEMO_REVIEWS: ReviewItem[] = [
{
id: "demo-1",
authorName: "Daniel R.",
rating: 5,
text: "Best skin fade I've had in years. Booked online in under a minute.",
relativeDate: "2 weeks ago",
},
{
id: "demo-2",
authorName: "Priya K.",
rating: 5,
text: "Great atmosphere and the loyalty card is a nice touch.",
relativeDate: "1 month ago",
},
{
id: "demo-3",
authorName: "Marcus T.",
rating: 4,
text: "Solid haircut, a little wait past my appointment time.",
relativeDate: "1 month ago",
},
];
class DemoReviewsProvider implements ReviewsProvider {
async getBusinessReviews(_businessId: string): Promise<BusinessReviewSummary> {
const totalReviews = 1142; // matches the figure shown in templates/15
const averageRating = 5.0;
return { averageRating, totalReviews, reviews: DEMO_REVIEWS, isDemoData: true };
}
}
/** Exposed so tests can restore the default provider after swapping it — see reviews.service.test.ts. */
export function createDemoReviewsProvider(): ReviewsProvider {
return new DemoReviewsProvider();
}
let provider: ReviewsProvider = new DemoReviewsProvider();
/** Swap the provider (e.g. in a future Google-backed implementation, or a test double) without touching any caller. */
export function setReviewsProvider(next: ReviewsProvider): void {
provider = next;
}
export async function getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
return provider.getBusinessReviews(businessId);
}
@@ -156,7 +156,26 @@ export async function createTestCustomer(business: TestBusiness): Promise<{ cust
return { customerId: user.customerProfile!.id, userId: user.id };
}
/** A guest CustomerProfile — no linked User, matching the shape CustomerService's guest-matching creates. */
export async function createTestGuestCustomer(
overrides: { email?: string; phone?: string } = {},
): Promise<{ customerId: string; email: string; phone: string }> {
const email = overrides.email ?? `${uniqueId("guest")}@test.local`;
const phone = overrides.phone ?? `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`;
const created = await prisma.customerProfile.create({
data: { firstName: "Guest", lastName: "Customer", email, phone, isGuest: true },
});
return { customerId: created.id, email, phone };
}
export async function cleanupTestBusiness(business: TestBusiness): Promise<void> {
// Appointment.staff is onDelete: Restrict (see prisma/schema.prisma) — a
// deliberate production safeguard against deleting a staff member with
// appointment history, but it also blocks Business's own cascade delete
// to StaffProfile whenever a test actually booked something. Clearing
// appointments first lets that cascade complete instead of the business
// delete silently failing and leaking a "Test Barbershop" row per run.
await prisma.appointment.deleteMany({ where: { businessId: business.businessId } }).catch(() => {});
await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
if (business.createdUserIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
+10 -1
View File
@@ -186,7 +186,16 @@ export async function rescheduleBooking(prisma: PrismaClient, input: RescheduleB
export interface CancelBookingInput {
appointmentId: string;
cancelledByUserId: string;
/**
* Omit for a guest customer cancelling their own booking — a guest
* CustomerProfile has no linked User (see prisma/schema.prisma), so
* there's nothing to attribute the cancellation to beyond the
* appointment record itself. Widened from required to optional in
* Phase 4 (a genuine missing capability found while building guest
* self-service cancellation, not a redesign) — every existing caller
* that already passes a userId is unaffected.
*/
cancelledByUserId?: string;
reason?: string;
}