Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres.
14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing.
Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically.
Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds):
A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own).
A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice.
A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3.
Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled.
Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
66 files changed
+4599 -107

No files matched your search

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