diff --git a/.gitignore b/.gitignore index 25a638c..633f19f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ node_modules/ # testing coverage/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ # next.js .next/ diff --git a/docs/phase-4-summary.md b/docs/phase-4-summary.md new file mode 100644 index 0000000..bd8c04a --- /dev/null +++ b/docs/phase-4-summary.md @@ -0,0 +1,127 @@ +# Phase 4 — Customer Booking Experience: Summary + +Full guest/registered customer journey — browse the business, pick services, pick a barber/date/time, enter details, review, confirm, and manage the resulting appointment (view, reschedule, cancel, book again). Every business rule is enforced by the Phase 3 domain services; nothing in this phase re-implements availability, validation, or booking logic. + +--- + +## 1. What was built + +- **Customer Home** (`/home`) — real business data: hero, opening status, description, services preview, barber list, reviews (demo-labelled), opening hours, contact links. For a signed-in customer with a profile: greeting, upcoming-appointment card, loyalty progress (if the programme is enabled), preferred barber, and a "Book Again" shortcut. Guests see the same public page with no personalised sections. +- **Full booking flow** (`/book → /book/barber-time → /book/details → /book/review → /book/confirmation`) — multi-service selection, specific-barber-or-"anyone available" selection, a shared calendar/time-slot picker, guest-or-authenticated details capture, a review step with edit-any-step links, and a confirmation screen with add-to-calendar, guest account-creation prompt (never forced), and next-step actions. +- **Customer Appointments** (`/appointments`) — Upcoming/Past/Cancelled tabs backed by real data, per-card actions gated by status (View/Reschedule/Cancel/Book Again). +- **Appointment Details** (`/appointments/[id]`) — full appointment record, cancellation details when applicable, calendar export, status-appropriate actions. +- **Reschedule** (`/appointments/[id]/reschedule`) — reuses the same barber/date/time picker as new bookings, single-step, redirects to the appointment's details page on success. +- **Cancel** — a Modal-based confirmation dialog (optional reason, late-cancellation notice), never deletes the appointment. +- **Book Again** — starts a new booking with the previous services (and barber, if still bookable) preselected; always requires a fresh date/time and always reprices at current service prices. +- **Add to Calendar** — an RFC 5545 `.ics` file generated server-side, served from a Route Handler, ownership-checked like every other appointment access. +- **Reviews** — a provider-abstracted demo dataset, clearly labelled "Demo data" in the UI, so a real Google Reviews integration can replace it later without touching the Home page. +- **Guest identity** — a documented, conservative matching strategy (see §7) resolving which `CustomerProfile` a guest booking belongs to, without ever attaching a booking to an existing account without verification. + +## 2. Routes created / updated + +New: +- `/book`, `/book/barber-time`, `/book/details`, `/book/review`, `/book/confirmation` (own layout, outside `(customer)`, no bottom nav — guests must reach it) +- `/appointments/[id]`, `/appointments/[id]/reschedule` (reschedule is deliberately **outside** the `(customer)` route group too — see §12) +- `/api/appointments/[id]/calendar.ics` (the one legitimate Route Handler this phase; everything else is a Server Action) + +Updated: +- `/home` — rewritten from a placeholder to real data +- `/appointments` — rewritten from a placeholder to real data +- `/settings` — added a guest sign-in prompt (previously always required auth via `proxy.ts`) +- `(customer)/layout.tsx` and `src/proxy.ts` — `/home`, `/appointments`, `/settings` are no longer hard-gated by the proxy; each page now shows its own in-context sign-in prompt for a guest instead of a blanket redirect, because Home must render a public, unauthenticated experience + +## 3. Components created + +Feature components (`src/features/booking/components/`): `ServiceSelector`, `BarberDateTimePicker` (shared by booking **and** rescheduling), `DetailsForm`, `ConfirmBookingButton`, `CancelAppointmentDialog`, `BookAgainButton`. + +Supporting: `src/lib/business/get-primary-business.ts`, `src/lib/business/opening-status.ts`, `src/lib/booking-draft/*` (cookie, schema, HMAC signing), `src/lib/calendar/ics.ts`. + +No new design-system primitives were needed — Phase 2's Button/Input/Select/Checkbox/Textarea/Modal/Card/Tabs/EmptyState/StatusPill/AppointmentCard covered everything. + +## 4. Domain-service changes + +Minimal, on the "genuine missing capability" bar the brief set: + +- **`BookingService.CancelBookingInput.cancelledByUserId`** widened from required to optional — a guest `CustomerProfile` has no linked `User`, so a guest cancelling their own booking has nothing to attribute it to. Every existing caller that already passes a `userId` is unaffected. +- **New `CustomerService`** (`src/services/customer/customer.service.ts`) — anticipated by Phase 3's own "future services" list. Owns exactly one thing: resolving which `CustomerProfile` a booking belongs to (see §7). Calls no other domain service and is called by, not merged into, `BookingService`. +- **`BookingValidationErrorCode`** gained one entry, `INVALID_INPUT`, for malformed Server Action input that never reaches a real validation check. +- **`src/features/booking/authorization.ts`** (new, not a domain service) — `assertCanAccessAppointment` / `canAccessAppointment`. Deliberately kept in the feature layer, not `AppointmentService`, because ownership rules differ for future staff/owner tooling ("any staff member in this business") — see the file's own header comment. + +No changes to `AvailabilityService`, `SchedulingService`, `StaffAvailabilityService`, or `BusinessRulesService`. + +## 5. Prisma schema changes + +None beyond what Phase 3 already had. `cancelledByUserId` was already a nullable column; only the TypeScript input type changed (§4). + +## 6. Booking-state approach + +An HMAC-**signed but not encrypted** cookie (`nc_booking_draft`, 1 hour, httpOnly, secure in prod, `sameSite=lax`), keyed off `AUTH_SECRET`, holding the draft (services, staff, date/time, guest details, notes, policy acknowledgement). A separate short-lived cookie (`nc_last_booking`, 30 min) carries only the newly created appointment's id to the confirmation page, so that page never needs a URL query parameter. + +**Why signed-only, not encrypted:** the threat model here is *tampering*, not *disclosure* — the cookie holds the customer's own freshly-typed data, on the app's own domain, not anyone else's secrets. Signing detects tampering; the customer already has the data. Every value is still re-validated server-side at confirm time regardless of what the cookie says (see §8). + +Every `/book/*` step is a Server Action that reads, mutates, and rewrites this cookie, then `redirect()`s to the next step — so a refresh, browser back, or an in-flow sign-in never loses progress, and no client-only state is load-bearing. + +## 7. Guest-customer matching approach + +Documented in full in `src/services/customer/customer.service.ts`; summary: + +1. An authenticated caller always books under their own profile (found via `User.customerProfile`, created on first booking if missing). No matching logic runs at all — there's no ambiguity to resolve. +2. A guest caller is matched against an existing profile only if **both** email (case-insensitive, normalised in application code — not Prisma's `mode: "insensitive"`, which is Postgres/Mongo-only and would break on a MySQL swap) **and** phone match exactly, against `isGuest = true AND userId IS NULL` profiles only. +3. A guest is never matched against a registered customer's profile, even on an exact email match — that would attach a guest's booking to a real account without verification. +4. No match → a new guest profile is created. Existing-profile fields are never overwritten on a match. +5. **Guest appointment access is "order tracking" by unguessable id**, not by verified identity — no magic-link/OTP system was built this phase. This is a deliberate, documented scope limitation (see §12), not an oversight. + +## 8. Security / ownership rules + +- `assertCanAccessAppointment` re-checked on **every** appointment read/mutation a customer can trigger (details, reschedule, cancel, book-again, calendar export) — an appointment id is never treated as permission on its own for a registered customer's booking. +- Every mutating Server Action re-derives the session server-side; none trust a client-supplied user or customer id. +- `confirmBookingAction` resolves the customer via `CustomerService` from the session/cookie, never from client input — a customer cannot submit someone else's `customerId`. +- Price, duration, and total are never taken from the client. `BookingService.createBooking` always re-reads the current `Service` rows and re-validates the slot inside its own transaction at commit time (this is Phase 3 behaviour, unchanged — Phase 4 never bypasses it). +- No sensitive customer data is placed in a URL — see §6. +- Raw Prisma access is confined to Server Components/Actions; no `src/generated/prisma` import exists in a Client Component. + +## 9. Tests written + +- **Unit** (Vitest, no DB, part of the existing 90-test suite): `sign.test.ts` (HMAC sign/verify round-trip and tamper rejection), plus targeted tests for the booking-draft schema, error-code→message mapping, and the `.ics` generator's escaping/folding/timezone handling. +- **Integration** (Vitest, real throwaway Postgres, `src/features/booking/__tests__/customer-booking.integration.test.ts`, 14 new tests on top of Phase 3's 24): guest booking (new profile, case-insensitive reuse, one-weak-identifier-never-merges), authenticated booking (idempotent profile resolution), multi-service + specific-barber booking, any-barber booking (least-busy assignment), slot-becomes-unavailable-before-confirmation, customer appointment retrieval (own-only, status-filtered), guest cancellation without `cancelledByUserId`, reschedule to a different barber, book-again repricing at the *current* price rather than the historical snapshot, ownership enforcement (own/stranger/undefined session), and guest-to-guest data isolation. All 38 integration tests pass, run repeatedly against a fresh database. +- **End-to-end** (Playwright, `e2e/`, real dev server + real seeded Postgres): the 6 required journeys — guest books, registered customer books, customer views upcoming/past/cancelled, customer reschedules, customer cancels, customer books again from history. Every journey drives real forms, real Server Actions, and real redirects; none rely on mocks or snapshots. `e2e/global-setup.ts` applies migrations and the seed automatically given a `DATABASE_URL`. + +Running them: +```bash +npm run test # unit, no DB +npm run test:integration # needs DATABASE_URL → a real Postgres +npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres; starts its own dev server +``` + +## 10. What was manually verified + +- Full guest booking flow and full authenticated booking flow, driven by hand in a real browser against seeded data, on both a 375px mobile viewport and a 1440px desktop viewport. +- The `/dev/scheduling-test` page's calendar against the same seeded data, to isolate a date-handling bug (see §12) from the Phase 4 UI before fixing it. +- `npm run build`, `npx tsc --noEmit`, and `npx eslint .` all clean on the final state. +- All three automated suites (90 unit / 38 integration / 6 E2E) passed together on one freshly created, migrated, and seeded database, more than once, including immediately after every fix described in §12. + +## 11. Assumptions made + +- **Single business.** `getPrimaryBusiness()` still assumes exactly one `Business` row, per Phase 2/3's existing assumption — unchanged this phase, just consumed by more pages now. +- **"Update booking" from the customer's side means notes, reschedule, or cancel — never a direct date/time edit outside `rescheduleBooking`.** Matches Phase 3's own stated assumption. +- **A returning guest's identity is trusted only as far as email+phone go.** No stronger verification (OTP, magic link) was in scope. +- **Reviews are demo data** until a real provider is wired up behind the existing `ReviewsProvider` abstraction. +- **"Book Again" preselects only currently-active services and a currently-bookable barber** — anything since discontinued or deactivated is silently dropped from the preselection rather than blocking the rebooking. + +## 12. Known limitations (and what was fixed this phase after being found by testing) + +Found and fixed while verifying, not left in place: + +- **A date off-by-one bug** in the shared barber/date/time picker (`toYMD` in `barber-date-time-picker.tsx` and the same pattern in the Phase 3 dev-test page) used `date.toISOString().slice(0, 10)` to key calendar availability, which silently shifts a date back by one day for anyone in a timezone *ahead* of UTC — including the business's own Europe/London during BST. Real Mondays showed as closed. Fixed by reading the `Date` object's local components instead of round-tripping through UTC; verified by comparing the dev-test page's calendar before and after against the same seeded staff hours. +- **A dialog-unmounts-mid-confirmation bug** in `CancelAppointmentDialog`: `cancelCustomerAppointmentAction` called `revalidatePath("/appointments")` immediately on success, which refetches the Upcoming list before the user can read the late-cancellation notice or click "Done" — the cancelled appointment disappears from that list, unmounting the dialog showing the confirmation out from under them. Removed the `revalidatePath` calls (every affected page is already `dynamic = "force-dynamic"`, so they bought no real cache freshness) and now rely on the dialog's own `router.refresh()` on close, which was already there. +- **A silent test-cleanup leak** in the Phase 3 integration fixtures: `cleanupTestBusiness` tried to delete the test `Business` first, but `Appointment.staff` is `onDelete: Restrict` (a deliberate production safeguard), which blocked the cascade to `StaffProfile` for any test that had actually booked something — the failure was swallowed by a bare `.catch(() => {})`. This had been silently leaking a "Test Barbershop" row per affected test run since Phase 3; it only surfaced now because a manual verification pass queried `getPrimaryBusiness()` against a database that had just run the integration suite and picked up a leaked row instead of the real seed. Fixed by deleting the business's appointments before the business itself. +- **Desktop layout was effectively unstyled for the "browsing" screens** (Home, Appointments, Settings, and the bottom nav) — content simply didn't reflow or cap its width above mobile size, leaving a large empty right-hand column rather than anything intentional. Applied a **baseline** fix (a centered `max-w-2xl` content column, matching the pattern the booking flow's individual steps already used) across Home, Appointments, Settings, the shared `BottomNav`, `BarberDateTimePicker`, and `DetailsForm`. **This is not a full desktop redesign** — no side-by-side / grid layouts were built for browsing screens, only a consistent centered treatment so nothing looks broken. A genuine desktop-specific layout (e.g. a wider services grid, a split-pane appointment list) is a reasonable Phase 5+ item if desktop usage turns out to matter. +- **Reschedule's route was moved out of the `(customer)` route group** (`(customer)/appointments/[id]/reschedule` → top-level `appointments/[id]/reschedule`) after discovering the shared picker's own fixed bottom action bar was being covered by the persistent bottom nav on that route, blocking the "Confirm Reschedule" button. Matches the same reasoning `/book`'s layout already documents. +- **Guest appointment access remains order-tracking-by-id only** (§7, point 5) — not a bug, but worth restating as the clearest remaining security-adjacent scope boundary: a guest with the appointment id can view/reschedule/cancel it without further verification. Fine for this phase's threat model (ids are unguessable `cuid`s, never enumerable), but a real magic-link/OTP flow would close this gap properly. + +## 13. Before Phase 5 + +- Decide whether guest-booking access needs stronger verification (magic link / OTP) before this becomes a compliance concern, or whether "unguessable id" remains acceptable for the foreseeable future. +- If desktop usage is expected to matter, budget real design time for Home/Appointments rather than the baseline centering applied here. +- The `e2e/` suite and `test:integration` script both assume a disposable Postgres the caller provides — there's no CI wiring yet to spin one up automatically; that's worth doing before this becomes the main safety net. +- Loyalty UI, staff/owner-facing screens, notifications, and payments are all still untouched, exactly as scoped. diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts new file mode 100644 index 0000000..3efbcd7 --- /dev/null +++ b/e2e/customer-journeys.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from "@playwright/test"; +import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers"; + +/** + * The 6 customer journeys required by the Phase 4 brief. Each drives real + * forms against the real dev server and a real (seeded) Postgres database — + * see e2e/global-setup.ts for how that database gets prepared, and + * prisma/seed.ts for the fixtures these tests rely on (Alex Chen's + * existing upcoming/completed appointments, the three barbers, the three + * services). Journeys are written to be independent of each other and of + * run order: each either reads pre-seeded data or books its own fresh + * appointment before acting on it. + */ + +test.describe("customer booking journeys", () => { + test("1. guest books an appointment", async ({ page }) => { + const guest = { + firstName: "Guest", + lastName: "Journey", + email: uniqueEmail("guest-journey"), + phone: "+44 7700 900901", + }; + + await page.goto("/home"); + await page.getByRole("link", { name: "Book an Appointment" }).click(); + + await completeBookingFlow(page, { + serviceName: SEEDED.services.haircut, + staffName: SEEDED.owner.name, + guest, + }); + + // Guest confirmation offers an account-creation prompt but doesn't force it. + await expect(page.getByText("Want to manage this booking later?")).toBeVisible(); + await expect(page.getByRole("link", { name: "Create an Account" })).toBeVisible(); + await expect(page.getByRole("link", { name: "View Appointment" })).toHaveCount(0); + }); + + test("2. registered customer books an appointment", async ({ page }) => { + await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + + await page.goto("/book"); + await completeBookingFlow(page, { + serviceName: SEEDED.services.skinfade, + staffName: SEEDED.barberSkinfadeBeard.name, + }); + + // Registered customers get a direct link to their appointments instead of the guest account prompt. + await expect(page.getByRole("link", { name: "View Appointment" })).toBeVisible(); + + await page.goto("/appointments"); + await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible(); + }); + + test("3. customer views upcoming appointments", async ({ page }) => { + await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await page.goto("/appointments"); + + // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed. + await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active"); + const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name); + await expect(upcomingSeeded).toBeVisible(); + await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible(); + + // Seeded: a COMPLETED Haircut & Style with Leo Vance. + await page.getByRole("tab", { name: "Past" }).click(); + const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name); + await expect(pastSeeded).toBeVisible(); + await expect(pastSeeded.getByText("Completed")).toBeVisible(); + + // Alex Chen has no cancelled appointments in the seed data. + await page.getByRole("tab", { name: "Cancelled" }).click(); + await expect(page.getByText("No cancelled appointments")).toBeVisible(); + }); + + test("4. customer reschedules an appointment", async ({ page }) => { + await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + + // Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests. + await page.goto("/book"); + await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.owner.name }); + + await page.goto("/appointments"); + const card = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.owner.name); + await expect(card).toBeVisible(); + const originalTime = (await card.innerText()).match(/\d{1,2}:\d{2}/)?.[0]; + expect(originalTime).toBeTruthy(); + + await card.getByRole("link", { name: "Reschedule" }).click(); + await completeRescheduleFlow(page); + + // Landed back on the details page for the same appointment, now at a different time. + await expect(page.getByRole("heading", { name: "Appointment Details" })).toBeVisible(); + await expect(page.getByText(SEEDED.services.beardTrim)).toBeVisible(); + const newTime = (await page.locator("main").innerText()).match(/\d{1,2}:\d{2}/)?.[0]; + expect(newTime).toBeTruthy(); + expect(newTime).not.toBe(originalTime); + // Same appointment id (still reschedulable/cancellable — proves it wasn't recreated). + await expect(page.getByRole("link", { name: "Reschedule" })).toBeVisible(); + }); + + test("5. customer cancels an appointment", async ({ page }) => { + await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + + await page.goto("/book"); + await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name }); + + await page.goto("/appointments"); + const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name); + await expect(upcomingCard).toBeVisible(); + + await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click(); + // Scoped to the currently-open — the Upcoming tab can have several + // cancellable cards, each with its own (closed) dialog in the DOM. + const dialog = page.locator("dialog[open]"); + await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible(); + await dialog.getByPlaceholder("Reason (optional)").fill("E2E test cancellation"); + await dialog.getByRole("button", { name: "Cancel it" }).click(); + + await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible(); + await dialog.getByRole("button", { name: "Done" }).click(); + + // Cancellation never deletes — it moves from Upcoming to the Cancelled tab. + await expect(findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name)).toHaveCount(0); + await page.getByRole("tab", { name: "Cancelled" }).click(); + const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name); + await expect(cancelledCard).toBeVisible(); + await expect(cancelledCard.getByText("Cancelled")).toBeVisible(); + }); + + test("6. customer books again from history", async ({ page }) => { + await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await page.goto("/appointments"); + + // Seeded COMPLETED appointment (Haircut & Style with Leo Vance). + await page.getByRole("tab", { name: "Past" }).click(); + const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name); + await completedCard.getByRole("button", { name: "Book Again" }).click(); + + // Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time. + await expect(page).toHaveURL(/\/book$/); + await expect(page.getByText("Booking again")).toBeVisible(); + + await completeBookingFlow(page, { servicesPreselected: true }); + + await expect(page.getByRole("link", { name: "View Appointment" })).toBeVisible(); + + // A brand new appointment now exists alongside the historical one — the completed appointment is untouched. + await page.goto("/appointments"); + await expect(findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name)).toBeVisible(); + await page.getByRole("tab", { name: "Past" }).click(); + await expect(completedCard).toBeVisible(); + }); +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..18e751f --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,21 @@ +import { execFileSync } from "node:child_process"; + +/** + * Applies migrations and loads the dev/demo seed data against whatever + * DATABASE_URL points at before the suite runs. Deliberately does not + * create or tear down the database itself — same division of + * responsibility as `npm run test:integration`, where the caller is + * responsible for pointing DATABASE_URL at a disposable Postgres. + */ +export default function globalSetup() { + if (!process.env.DATABASE_URL) { + throw new Error( + "DATABASE_URL is not set. Point it at a disposable Postgres before running the e2e suite, e.g.\n" + + ' DATABASE_URL="postgresql://user:pass@localhost:5432/nextchair_e2e" npm run test:e2e', + ); + } + + const env = { ...process.env }; + execFileSync("npx", ["prisma", "migrate", "deploy"], { stdio: "inherit", env }); + execFileSync("npx", ["tsx", "prisma/seed.ts"], { stdio: "inherit", env }); +} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..dbeddf1 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,117 @@ +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */ +export const SEEDED = { + registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" }, + owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" }, + barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" }, + barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" }, + services: { + haircut: "Haircut & Style", + skinfade: "Skinfade / Taper & Style", + beardTrim: "Beard Trim & Shape", + }, +}; + +export async function signIn(page: Page, email: string, password: string): Promise { + await page.goto("/sign-in"); + await page.getByLabel("Email Address").fill(email); + await page.getByLabel("Password", { exact: true }).fill(password); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + await expect(page).toHaveURL(/\/home/); +} + +function section(page: Page, headingName: string) { + return page.locator("section").filter({ has: page.getByRole("heading", { name: headingName, exact: true }) }); +} + +/** + * Drives the real /book flow end to end — every step is a genuine form + * submission through the actual Server Actions, matching the Phase 4 + * brief's requirement that these journeys not rely on mocks or snapshots. + * + * Assumes the page is already on /book (or /book/barber-time if + * `servicesPreselected` — e.g. arriving via "Book Again", which + * preselects services and redirects straight past the services step). + */ +export async function completeBookingFlow( + page: Page, + options: { + serviceName?: string; + servicesPreselected?: boolean; + staffName?: string; // omit for "Anyone available" + guest?: { firstName: string; lastName: string; email: string; phone: string }; + }, +): Promise { + const { serviceName, servicesPreselected = false, staffName, guest } = options; + + await expect(page).toHaveURL(/\/book$/); + if (!servicesPreselected) { + if (!serviceName) throw new Error("serviceName is required unless servicesPreselected is true"); + await page.getByRole("button", { name: new RegExp(`^${serviceName}`) }).click(); + } + // Even with services preselected (e.g. "Book Again"), the customer still confirms this step themselves. + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page).toHaveURL(/\/book\/barber-time/); + if (staffName) { + await section(page, "Barber").locator("select").selectOption({ label: staffName }); + // Choosing a barber refetches that barber's specific availability — + // wait for it to settle so the date grid below reflects the right person. + await page.waitForLoadState("networkidle"); + } + // Wait for the date grid to (re)render for the chosen barber before picking a day. + await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible(); + await section(page, "Date").locator("button[aria-pressed]:not([disabled])").first().click(); + const timeSection = section(page, "Time"); + await expect(timeSection.locator("button").first()).toBeVisible(); + await timeSection.locator("button").first().click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + + await expect(page).toHaveURL(/\/book\/details/); + if (guest) { + await page.getByLabel("First Name").fill(guest.firstName); + await page.getByLabel("Last Name").fill(guest.lastName); + await page.getByLabel("Email Address").fill(guest.email); + await page.getByLabel("Phone Number").fill(guest.phone); + } + await page.getByLabel("I acknowledge the cancellation policy").check(); + await page.getByRole("button", { name: "Continue to Review" }).click(); + + await expect(page).toHaveURL(/\/book\/review/); + await page.getByRole("button", { name: "Confirm Booking" }).click(); + + await expect(page).toHaveURL(/\/book\/confirmation/); + await expect(page.getByRole("heading", { name: "You're all set!" })).toBeVisible(); +} + +/** Locates a specific appointment card on /appointments by its rendered service + barber text, scoping later actions (Reschedule/Cancel/Book Again) to that one card so they can't accidentally hit a different appointment. */ +export function findAppointmentCard(page: Page, serviceName: string, staffName: string) { + return page.locator(".rounded-jumbo").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` }); +} + +export function uniqueEmail(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}@e2e-test.local`; +} + +/** + * Drives /appointments/[id]/reschedule — same shared BarberDateTimePicker + * as booking, but a single step that redirects straight to the + * appointment's details page. Deliberately picks the *last* available + * date/slot rather than the first: the original appointment's own slot + * becomes available again once it's excluded from the conflict check + * (see `excludeAppointmentId` in rescheduleBooking), so "first available" + * could otherwise reselect the exact same time and make the reschedule a + * no-op from the test's point of view. + */ +export async function completeRescheduleFlow(page: Page): Promise { + await expect(page).toHaveURL(/\/reschedule/); + await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible(); + await section(page, "Date").locator("button[aria-pressed]:not([disabled])").last().click(); + const timeSection = section(page, "Time"); + await expect(timeSection.locator("button").first()).toBeVisible(); + await timeSection.locator("button").last().click(); + await page.getByRole("button", { name: "Confirm Reschedule", exact: true }).click(); + await expect(page).toHaveURL(/\/appointments\/[^/]+$/); +} diff --git a/package-lock.json b/package-lock.json index 0ff28b4..b4278f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,10 +27,12 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.84.0", + "server-only": "^0.0.1", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.3.3", "@types/bcryptjs": "^2.4.6", "@types/luxon": "^3.7.3", @@ -2036,6 +2038,22 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz", @@ -8876,6 +8894,52 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -9588,6 +9652,12 @@ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", "devOptional": true }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index a2eb00f..acac348 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "prisma:seed": "tsx prisma/seed.ts", "test": "vitest run", "test:watch": "vitest", - "test:integration": "vitest run --config vitest.integration.config.mts" + "test:integration": "vitest run --config vitest.integration.config.mts", + "test:e2e": "playwright test" }, "repository": { "type": "git", @@ -44,10 +45,12 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-hook-form": "^7.84.0", + "server-only": "^0.0.1", "tailwind-merge": "^3.6.0", "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tailwindcss/postcss": "^4.3.3", "@types/bcryptjs": "^2.4.6", "@types/luxon": "^3.7.3", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..0f99451 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * End-to-end customer journeys — real browser, real dev server, real + * Postgres. Needs DATABASE_URL pointed at a throwaway database before + * running (same "migrate, seed, test, tear down" pattern as + * `npm run test:integration` — see docs/phase-3-summary.md and + * docs/phase-4-summary.md). `globalSetup` applies migrations and seed + * data automatically so a single `npm run test:e2e` is enough once + * DATABASE_URL is set. + */ +const PORT = 3100; +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 45_000, + expect: { timeout: 10_000 }, + reporter: "list", + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: `npm run dev -- --port ${PORT}`, + url: baseURL, + reuseExistingServer: false, + timeout: 60_000, + env: { + ...process.env, + DATABASE_URL: process.env.DATABASE_URL ?? "", + // Any fixed value is fine — see src/lib/booking-draft/sign.ts. Only + // needs to be consistent for the lifetime of this one server process. + AUTH_SECRET: process.env.AUTH_SECRET ?? "playwright-e2e-test-secret-do-not-use-in-production", + NEXT_PUBLIC_APP_URL: baseURL, + }, + }, +}); diff --git a/prisma/seed.ts b/prisma/seed.ts index 84d2307..f4d03f5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,6 +8,17 @@ import { PrismaClient, Permission, Weekday, RewardType } from "../generated/pris import { PrismaPg } from "@prisma/adapter-pg"; import bcrypt from "bcryptjs"; +/** + * All dates below are computed relative to "now" so the seed stays useful + * (an upcoming appointment stays upcoming) no matter when it's re-run. + */ +function daysFromNow(days: number, hour: number, minute = 0): Date { + const date = new Date(); + date.setDate(date.getDate() + days); + date.setHours(hour, minute, 0, 0); + return date; +} + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); const prisma = new PrismaClient({ adapter }); @@ -129,7 +140,17 @@ async function main() { }, }); - const [haircut, skinfade] = await Promise.all([ + const beardCategory = await prisma.serviceCategory.upsert({ + where: { id: `${business.id}-beard-services` }, + update: {}, + create: { + id: `${business.id}-beard-services`, + businessId: business.id, + name: "Beard Services", + }, + }); + + const [haircut, skinfade, beardTrim] = await Promise.all([ prisma.service.upsert({ where: { id: `${business.id}-haircut-style` }, update: {}, @@ -154,6 +175,18 @@ async function main() { price: 25, }, }), + prisma.service.upsert({ + where: { id: `${business.id}-beard-trim-shape` }, + update: {}, + create: { + id: `${business.id}-beard-trim-shape`, + businessId: business.id, + categoryId: beardCategory.id, + name: "Beard Trim & Shape", + durationMinutes: 20, + price: 15, + }, + }), ]); const ownerPasswordHash = await bcrypt.hash("password123", 12); @@ -178,7 +211,18 @@ async function main() { })), }, services: { - create: [{ serviceId: haircut.id }, { serviceId: skinfade.id }], + create: [{ serviceId: haircut.id }, { serviceId: skinfade.id }, { serviceId: beardTrim.id }], + }, + // Daily lunch break, Mon-Sat — exercises the "unavailable slot mid-day" path. + breaks: { + create: [ + { + label: "Lunch", + startMinutes: 13 * 60, + endMinutes: 13 * 60 + 30, + days: { create: WEEKDAYS.map((dayOfWeek) => ({ dayOfWeek })) }, + }, + ], }, }, }, @@ -187,7 +231,7 @@ async function main() { }); const barberPasswordHash = await bcrypt.hash("password123", 12); - await prisma.user.upsert({ + const barberUser = await prisma.user.upsert({ where: { email: "leo@blakeheritage.co.uk" }, update: {}, create: { @@ -208,13 +252,59 @@ async function main() { })), }, services: { create: [{ serviceId: haircut.id }] }, + // A week of approved leave starting next week — exercises the "staff on time off" path. + timeOff: { + create: [ + { + startDate: daysFromNow(7, 0), + endDate: daysFromNow(11, 0), + reason: "Annual leave", + }, + ], + }, }, }, }, + include: { staffProfile: true }, + }); + + // Second barber: different days off (closed Sun+Mon rather than just Sun) + // and a different service mix — exercises "not every barber can do every + // service" and "any barber" resolution across staff with different rotas. + const secondBarberPasswordHash = await bcrypt.hash("password123", 12); + const secondBarberUser = await prisma.user.upsert({ + where: { email: "jamie@blakeheritage.co.uk" }, + update: {}, + create: { + email: "jamie@blakeheritage.co.uk", + name: "Jamie Wallace", + passwordHash: secondBarberPasswordHash, + emailVerified: new Date(), + staffProfile: { + create: { + businessId: business.id, + roleId: barberRole.id, + bio: "Fades and beard specialist.", + workingHours: { + create: [ + { dayOfWeek: Weekday.TUESDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, + { dayOfWeek: Weekday.WEDNESDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, + { dayOfWeek: Weekday.THURSDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, + { dayOfWeek: Weekday.FRIDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, + { dayOfWeek: Weekday.SATURDAY, startMinutes: 9 * 60, endMinutes: 17 * 60 }, + { dayOfWeek: Weekday.SUNDAY, isOff: true }, + { dayOfWeek: Weekday.MONDAY, isOff: true }, + ], + }, + services: { create: [{ serviceId: skinfade.id }, { serviceId: beardTrim.id }] }, + }, + }, + }, + include: { staffProfile: true }, }); const customerPasswordHash = await bcrypt.hash("password123", 12); - await prisma.user.upsert({ + const registeredCustomerUser = await prisma.user.upsert({ where: { email: "alex.chen@example.com" }, update: {}, create: { @@ -227,17 +317,105 @@ async function main() { firstName: "Alex", lastName: "Chen", email: "alex.chen@example.com", + phone: "+44 7700 900123", isGuest: false, preferredBarberId: ownerUser.staffProfile?.id, }, }, }, + include: { customerProfile: true }, }); + // Guest customer — no linked User, matches the CustomerService guest-matching + // rules (normalized email + exact phone) so re-running the booking flow with + // these same details reuses this profile instead of creating a duplicate. + const guestCustomer = await prisma.customerProfile.upsert({ + where: { id: `${business.id}-guest-jordan-blake` }, + update: {}, + create: { + id: `${business.id}-guest-jordan-blake`, + firstName: "Jordan", + lastName: "Blake", + email: "jordan.blake@example.com", + phone: "+44 7700 900456", + isGuest: true, + }, + }); + + const registeredCustomer = registeredCustomerUser.customerProfile; + if (registeredCustomer && ownerUser.staffProfile && barberUser.staffProfile) { + // Upcoming, confirmed — registered customer with the owner/barber. + const upcomingStart = daysFromNow(3, 11, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-upcoming-confirmed` }, + update: {}, + create: { + id: `${business.id}-appt-upcoming-confirmed`, + businessId: business.id, + customerId: registeredCustomer.id, + staffId: ownerUser.staffProfile.id, + status: "CONFIRMED", + startAt: upcomingStart, + endAt: new Date(upcomingStart.getTime() + haircut.durationMinutes * 60_000), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + + // Completed — a past visit, used to power "Book Again" and appointment history. + const completedStart = daysFromNow(-14, 15, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-completed` }, + update: {}, + create: { + id: `${business.id}-appt-completed`, + businessId: business.id, + customerId: registeredCustomer.id, + staffId: barberUser.staffProfile.id, + status: "COMPLETED", + startAt: completedStart, + endAt: new Date(completedStart.getTime() + haircut.durationMinutes * 60_000), + services: { + create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], + }, + }, + }); + } + + if (secondBarberUser.staffProfile) { + // Cancelled — guest customer, cancelled by the guest themselves (no + // cancelledByUserId, matching the Phase 4 guest-cancellation capability). + const cancelledStart = daysFromNow(5, 10, 0); + await prisma.appointment.upsert({ + where: { id: `${business.id}-appt-cancelled` }, + update: {}, + create: { + id: `${business.id}-appt-cancelled`, + businessId: business.id, + customerId: guestCustomer.id, + staffId: secondBarberUser.staffProfile.id, + status: "CANCELLED", + startAt: cancelledStart, + endAt: new Date(cancelledStart.getTime() + (skinfade.durationMinutes + beardTrim.durationMinutes) * 60_000), + cancellationReason: "Schedule conflict", + cancelledAt: daysFromNow(-1, 9, 0), + services: { + create: [ + { serviceId: skinfade.id, priceAtBooking: skinfade.price, durationAtBookingMinutes: skinfade.durationMinutes }, + { serviceId: beardTrim.id, priceAtBooking: beardTrim.price, durationAtBookingMinutes: beardTrim.durationMinutes }, + ], + }, + }, + }); + } + console.log("Seed complete:", { business: business.name, roles: [ownerRole.name, barberRole.name], - services: [haircut.name, skinfade.name], + services: [haircut.name, skinfade.name, beardTrim.name], + staff: [ownerUser.name, barberUser.name, secondBarberUser.name], + customers: [registeredCustomerUser.name, `${guestCustomer.firstName} ${guestCustomer.lastName} (guest)`], }); } diff --git a/src/app/(customer)/appointments/[id]/page.tsx b/src/app/(customer)/appointments/[id]/page.tsx new file mode 100644 index 0000000..a574d48 --- /dev/null +++ b/src/app/(customer)/appointments/[id]/page.tsx @@ -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 = { + 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 ( +
+ + +
+ +
+ + + Ref: {appointment.id.slice(-8)} + +
+ +
+

+ {appointment.services.map((s) => s.service.name).join(" + ")} +

+

+ with {appointment.staff.user.name} · {business.name} +

+
+ +
+
+ + Date + + {appointment.startAt.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: business.timezone, + })} +
+
+ + Time + + {appointment.startAt.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: business.timezone, + })} +
+
+ + Duration + + {totalDuration} min +
+
+ + Total + + £{totalPrice.toFixed(2)} +
+
+ + {appointment.notes && ( +
+ + Your Notes + +

{appointment.notes}

+
+ )} + + {appointment.status === "CANCELLED" && ( +
+ + Cancellation + +

+ Cancelled{" "} + {appointment.cancelledAt?.toLocaleString("en-GB", { timeZone: business.timezone })} + {cancelledByLabel ? ` by ${cancelledByLabel}` : ""} +

+ {appointment.cancellationReason && ( +

+ “{appointment.cancellationReason}” +

+ )} +
+ )} +
+ +
+ + + {business.phone && ( + + + )} +
+ +
+ {RESCHEDULABLE_STATUSES.has(appointment.status) && ( + + Reschedule + + )} + {CANCELLABLE_STATUSES.has(appointment.status) && ( + + )} + {REBOOKABLE_STATUSES.has(appointment.status) && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(customer)/appointments/page.tsx b/src/app/(customer)/appointments/page.tsx index e2cc671..a5ebeda 100644 --- a/src/app/(customer)/appointments/page.tsx +++ b/src/app/(customer)/appointments/page.tsx @@ -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 ; +const STATUS_MAP: Record = { + 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 ( +
+

Appointments

+ + Sign In + + } + /> +
+ ); + } + + const business = await getPrimaryBusiness(); + const customerProfile = await prisma.customerProfile.findUnique({ + where: { userId: session.user.id }, + }); + + if (!customerProfile) { + return ( +
+

Appointments

+ + Book Now + + } + /> +
+ ); + } + + 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 ( + + Book Now + + ) : undefined + } + /> + ); + } + + return ( +
+ {appointments.map((appointment) => ( + 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={ +
+ + View + + {kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && ( + + Reschedule + + )} + {kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && ( + + )} + {kind !== "upcoming" && } +
+ } + /> + ))} +
+ ); + }; + + return ( +
+

Appointments

+ + + Upcoming + Past + Cancelled + + {renderList(upcoming, "upcoming")} + {renderList(past, "past")} + {renderList(cancelled, "cancelled")} + +
+ ); } diff --git a/src/app/(customer)/home/page.tsx b/src/app/(customer)/home/page.tsx index 40f5567..559865c 100644 --- a/src/app/(customer)/home/page.tsx +++ b/src/app/(customer)/home/page.tsx @@ -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 = { + MONDAY: "Monday", + TUESDAY: "Tuesday", + WEDNESDAY: "Wednesday", + THURSDAY: "Thursday", + FRIDAY: "Friday", + SATURDAY: "Saturday", + SUNDAY: "Sunday", +}; + +const STATUS_MAP: Record = { + 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> = null; + let upcomingAppointment: UpcomingAppointment | null = null; + let loyaltyAccount: Awaited> = null; + let loyaltyProgramme: Awaited> = null; + let preferredBarberName: string | null = null; + let lastCompletedAppointment: Awaited> = 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 ( -
-

Signed in as

-

{session?.user?.name ?? session?.user?.email}

-

- The full personalized home (upcoming appointment, loyalty progress, quick booking) is designed - in templates/23-...html and scoped for a later phase — this route exists now to - prove sign-in → protected layout → bottom nav works end to end. -

+
+ {/* Hero */} +
+ {business.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+ +
+ {session?.user && ( +

+ Good to see you, {session.user.name?.split(" ")[0] ?? "there"} +

+ )} +

{business.name}

+ {location && ( +

+ {location.addressLine1}, {location.city} {location.postalCode} +

+ )} +
+
+
+ · {reviews.totalReviews.toLocaleString()} reviews + + {openingStatus.label} + +
+ {business.description && ( +

{business.description}

+ )} + + {/* Upcoming appointment */} + {upcomingAppointment && ( +
+

+ Your Next Visit +

+ 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={ + + View details + + } + /> +
+ )} + + {/* Loyalty summary */} + {loyaltyProgramme?.isEnabled && loyaltyAccount && ( + +
+

Loyalty Progress

+ + {loyaltyAccount.currentStamps}/{loyaltyProgramme.stampsRequired} stamps + +
+
+
+
+ + )} + + {/* Preferred barber + book again */} + {(preferredBarberName || lastCompletedAppointment) && ( +
+ {preferredBarberName && ( + +

+ Preferred Barber +

+

{preferredBarberName}

+
+ )} + {lastCompletedAppointment && ( + + )} +
+ )} + + {/* Booking CTA */} + + + {/* Services */} +
+
+

+ Popular Services +

+ + See all + +
+ + {previewServices.map((service) => ( + + Book + + } + /> + ))} + +
+ + {/* Barbers */} +
+

+ Our Barbers +

+
+ {staff.map((s) => ( + + ))} +
+
+ + {/* Reviews */} +
+
+

Reviews

+ {reviews.isDemoData && ( + + Demo data + + )} +
+
+ {reviews.reviews.map((review) => ( + +
+

{review.authorName}

+ {review.relativeDate} +
+
+ {Array.from({ length: review.rating }, (_, i) => ( +
+

{review.text}

+
+ ))} +
+
+ + {/* Hours */} +
+

+ Opening Hours +

+ + {WEEKDAY_ORDER.map((day) => { + const dayHours = business.hours.find((h) => h.dayOfWeek === day); + return ( +
+ {WEEKDAY_LABEL[day]} + + {!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")}`} + +
+ ); + })} +
+
+ + {/* Contact */} +
+

Contact

+
+ {business.phone && ( + + Call + + )} + {business.email && ( + + Email + + )} +
+
+
); } diff --git a/src/app/(customer)/layout.tsx b/src/app/(customer)/layout.tsx index 3431b31..ba7e6f5 100644 --- a/src/app/(customer)/layout.tsx +++ b/src/app/(customer)/layout.tsx @@ -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 (
{children} diff --git a/src/app/(customer)/settings/page.tsx b/src/app/(customer)/settings/page.tsx index ce416e6..7c6ee64 100644 --- a/src/app/(customer)/settings/page.tsx +++ b/src/app/(customer)/settings/page.tsx @@ -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 ( +
+

Settings

+ + Sign In + + } + /> +
+ ); + } + + const customerProfile = await prisma.customerProfile.findUnique({ + where: { userId: session.user.id }, + }); + return ( -
+

Settings

-

+

Preferred barber, loyalty card, and notification preferences are designed in - templates/10, 11, 35 and land in later phases. + templates/10, 11, 35 and land in a later phase.

diff --git a/src/app/api/appointments/[id]/calendar.ics/route.ts b/src/app/api/appointments/[id]/calendar.ics/route.ts new file mode 100644 index 0000000..167b1fa --- /dev/null +++ b/src/app/api/appointments/[id]/calendar.ics/route.ts @@ -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"`, + }, + }); +} diff --git a/src/app/appointments/[id]/reschedule/layout.tsx b/src/app/appointments/[id]/reschedule/layout.tsx new file mode 100644 index 0000000..21a4491 --- /dev/null +++ b/src/app/appointments/[id]/reschedule/layout.tsx @@ -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
{children}
; +} diff --git a/src/app/appointments/[id]/reschedule/page.tsx b/src/app/appointments/[id]/reschedule/page.tsx new file mode 100644 index 0000000..9d4fe2f --- /dev/null +++ b/src/app/appointments/[id]/reschedule/page.tsx @@ -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 ( +
+ + + +
+

{appointment.services.map((s) => s.service.name).join(" + ")} with {appointment.staff.user.name}

+

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

+
+
+ +
+ s.serviceId)} + initialStaffId={appointment.staffId} + onContinue={handleContinue} + continueLabel="Confirm Reschedule" + /> +
+
+ ); +} diff --git a/src/app/book/barber-time/page.tsx b/src/app/book/barber-time/page.tsx new file mode 100644 index 0000000..59f2859 --- /dev/null +++ b/src/app/book/barber-time/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/book/confirmation/page.tsx b/src/app/book/confirmation/page.tsx new file mode 100644 index 0000000..d9898bc --- /dev/null +++ b/src/app/book/confirmation/page.tsx @@ -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 = { + 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 ( +
+
+
+

You're all set!

+

+ We've confirmed your booking with {business.name}. +

+ + +
+ + Ref: {appointment.id.slice(-8)} +
+ +
+

{appointment.services.map((s) => s.service.name).join(" + ")}

+

with {appointment.staff.user.name}

+
+ +
+
+ Date + {appointment.startAt.toLocaleDateString("en-GB", { + weekday: "short", + day: "numeric", + month: "short", + timeZone: business.timezone, + })} +
+
+ Time + {appointment.startAt.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: business.timezone })} +
+
+ Duration + {appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0)} min +
+
+ Total£{totalPrice.toFixed(2)} +
+
+ + + +
+ +
+ {session?.user ? ( + + ) : ( + +

Want to manage this booking later?

+

+ Create an account to view your appointment history, save your details, and track loyalty rewards. +

+ +
+ )} + + +
+
+ ); +} diff --git a/src/app/book/details/page.tsx b/src/app/book/details/page.tsx new file mode 100644 index 0000000..3556fb7 --- /dev/null +++ b/src/app/book/details/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/book/layout.tsx b/src/app/book/layout.tsx new file mode 100644 index 0000000..0f31e4d --- /dev/null +++ b/src/app/book/layout.tsx @@ -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
{children}
; +} diff --git a/src/app/book/page.tsx b/src/app/book/page.tsx new file mode 100644 index 0000000..d93695a --- /dev/null +++ b/src/app/book/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/book/review/page.tsx b/src/app/book/review/page.tsx new file mode 100644 index 0000000..a62c8a7 --- /dev/null +++ b/src/app/book/review/page.tsx @@ -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 ( +
+ + +
+ +
+ + Business + + + {business.name} + +
+ +
+
+
+ + Services + +
    + {services.map((s) => ( +
  • {s.name}
  • + ))} +
+
+ + Edit + +
+ +
+
+ + Barber + +

{staff?.user.name ?? "Any available barber"}

+
+ + Edit + +
+ +
+
+ + Date & Time + +

+ {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, + })} +

+
+ + Edit + +
+
+ +
+
+ + Duration + +

{totalDuration} min

+
+
+ + Total + +

£{totalPrice.toFixed(2)}

+
+
+ +
+
+ + Your Details + + + Edit + +
+

{customerName}

+

+ {customerEmail} + {customerPhone ? ` · ${customerPhone}` : ""} +

+
+ + {draft.notes && ( +
+ + Notes + +

{draft.notes}

+
+ )} +
+ + +
+
+ ); +} diff --git a/src/app/dev/scheduling-test/page.tsx b/src/app/dev/scheduling-test/page.tsx index b4856f9..6d4af5d 100644 --- a/src/app/dev/scheduling-test/page.tsx +++ b/src/app/dev/scheduling-test/page.tsx @@ -38,7 +38,7 @@ export default async function SchedulingTestPage() { if (!customer) { return ( -
+
No seeded customer found — run npm run prisma:seed first.
); @@ -47,7 +47,12 @@ export default async function SchedulingTestPage() { return ( ({ 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} /> diff --git a/src/app/dev/scheduling-test/scheduling-test-client.tsx b/src/app/dev/scheduling-test/scheduling-test-client.tsx index dc49f5b..7aac656 100644 --- a/src/app/dev/scheduling-test/scheduling-test-client.tsx +++ b/src/app/dev/scheduling-test/scheduling-test-client.tsx @@ -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 = new Set(); diff --git a/src/app/globals.css b/src/app/globals.css index dd10886..d59bf8a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } } diff --git a/src/components/feedback/error-state.tsx b/src/components/feedback/error-state.tsx index 1a272e2..3439f5a 100644 --- a/src/components/feedback/error-state.tsx +++ b/src/components/feedback/error-state.tsx @@ -15,11 +15,11 @@ export function ErrorState({ }: ErrorStateProps) { return (
-
+

{title}

-

{description}

+

{description}

{onRetry && (
{error && ( - )} diff --git a/src/components/ui/status-pill.tsx b/src/components/ui/status-pill.tsx index 58463c2..0a79a62 100644 --- a/src/components/ui/status-pill.tsx +++ b/src/components/ui/status-pill.tsx @@ -2,21 +2,30 @@ import type { AppointmentStatus } from "@/types/domain"; import { cn } from "@/lib/utils/cn"; const STATUS_CONFIG: Record = { - 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 ( ( {label && (
+ ); +} + +const EMPTY_SLOTS: TimeSlot[] = []; diff --git a/src/features/booking/components/book-again-button.tsx b/src/features/booking/components/book-again-button.tsx new file mode 100644 index 0000000..a5f9751 --- /dev/null +++ b/src/features/booking/components/book-again-button.tsx @@ -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 ( + + ); +} diff --git a/src/features/booking/components/cancel-appointment-dialog.tsx b/src/features/booking/components/cancel-appointment-dialog.tsx new file mode 100644 index 0000000..b4c1e15 --- /dev/null +++ b/src/features/booking/components/cancel-appointment-dialog.tsx @@ -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(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 ( + <> + + + {result ? ( +
+

+ Your appointment has been cancelled. It won't be deleted from your history. +

+ {result.wasLateCancellation && ( +

+ This was a late cancellation, outside the free-cancellation window. The business may + apply a late-cancellation policy. +

+ )} + +
+ ) : ( +
+

+ This can't be undone. You can add an optional reason below. +

+