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

+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>
);
}