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