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

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