diff --git a/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md b/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md new file mode 100644 index 0000000..3e7c31d --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md @@ -0,0 +1,2426 @@ +# Phase 2C: Public Site Live Data Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Switch the public site (Shop, Home, Book) from static mock data to live Supabase queries, make booking/inquiry submission actually persist, and make product/service images resolve to real uploaded photos. + +**Architecture:** Replace static data-file imports with `supabase-js` fetch calls behind the same exported types (`Product`, `Service`, `TimeSlot`) so consuming components change minimally. Availability is computed by a pure, fully-tested function (`computeAvailableSlots`) fed by a thin data-fetching hook (`useAvailability`) — pure logic and I/O are kept separate, matching this project's established pattern of isolating computation from data fetching for testability. + +**Tech Stack:** React 19, TypeScript, `@supabase/supabase-js`, Vitest + React Testing Library, react-router-dom v7, Postgres/Supabase (already-deployed schema from Phase 2A, no new migrations). + +## Global Constraints + +- No new frontend dependencies — use `supabase-js`, React state, and what's already in `package.json` +- All new/changed user-facing strings must be in French, matching the rest of the already-translated site +- Database column names stay snake_case in queries; map to the existing camelCase `Product`/`Service` interfaces at the fetch boundary, not throughout the codebase +- `bookings.time` and `booked_slots.time` are `text` columns storing literal strings like `"9:00 AM"` (no leading zero on the hour, uppercase AM/PM) — this format must be preserved exactly, since it's already live production data +- Design spec: `docs/superpowers/specs/2026-08-12-phase2c-public-site-live-data-design.md` +- Live Supabase project ref: `ylxppnclfpnnolnjkbwv`. Local dev already points at this same live project via `.env` (not the local Docker CLI stack) — do not run `npx supabase db reset`/`start` as part of this plan, there's no local stack in the loop for this phase + +--- + +### Task 1: Image URL resolution helper + +**Files:** +- Create: `src/lib/resolveImageUrl.ts` +- Create: `src/lib/resolveImageUrl.test.ts` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts` +- Produces: `resolveImageUrl(path: string): string`. Consumed by Task 3 (`fetchProducts`) and Task 4 (`fetchServices`). + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/resolveImageUrl.test.ts`: + +```ts +import { describe, it, expect, vi } from 'vitest' + +const getPublicUrlMock = vi.fn() +const fromMock = vi.fn((bucketId: string) => { + fromMock.mock.calls.push([bucketId]) + return { getPublicUrl: getPublicUrlMock } +}) + +vi.mock('./supabase', () => ({ + supabase: { storage: { from: (bucketId: string) => fromMock(bucketId) } }, +})) + +import { resolveImageUrl } from './resolveImageUrl' + +describe('resolveImageUrl', () => { + it('resolves a storage-relative path to a public URL via the salon-images bucket', () => { + getPublicUrlMock.mockReturnValue({ + data: { publicUrl: 'https://example.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg' }, + }) + + const url = resolveImageUrl('products/edge-control.jpg') + + expect(fromMock).toHaveBeenCalledWith('salon-images') + expect(getPublicUrlMock).toHaveBeenCalledWith('products/edge-control.jpg') + expect(url).toBe('https://example.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg') + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/resolveImageUrl.test.ts` +Expected: FAIL — `./resolveImageUrl` does not exist yet. + +- [ ] **Step 3: Implement the helper** + +Create `src/lib/resolveImageUrl.ts`: + +```ts +import { supabase } from './supabase' + +export function resolveImageUrl(path: string): string { + return supabase.storage.from('salon-images').getPublicUrl(path).data.publicUrl +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/resolveImageUrl.test.ts` +Expected: PASS, 1 test. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: all pass (this task only adds a new isolated file). + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/resolveImageUrl.ts src/lib/resolveImageUrl.test.ts +git commit -m "feat: add resolveImageUrl helper for salon-images public URLs" +``` + +--- + +### Task 2: Real availability computation + +**Files:** +- Modify: `src/data/availability.ts` (replace the mock generator entirely) +- Modify: `src/data/availability.test.ts` (replace entirely) + +**Interfaces:** +- Consumes: nothing (pure function, no I/O) +- Produces: + ```ts + export interface TimeSlot { date: string; time: string; available: boolean } + export interface ScheduleTemplate { id: string; working_days: number[]; start_time: string; end_time: string; slot_minutes: number } + export interface ScheduleException { date: string; closed: boolean; note: string | null } + export interface BookedSlot { date: string; time: string } + export function computeAvailableSlots(template: ScheduleTemplate, exceptions: ScheduleException[], bookedSlots: BookedSlot[], date: string): TimeSlot[] + export function getNextAvailableDateFromSlots(template: ScheduleTemplate, exceptions: ScheduleException[], bookedSlotsByDate: Map, fromDate: string, searchWindowDays?: number): string | null + ``` + Consumed by Task 5 (`useAvailability` hook). `TimeSlot` is also consumed by `src/hooks/useBooking.ts` (unchanged import, already exists). + +- [ ] **Step 1: Write the failing tests** + +Replace `src/data/availability.test.ts` entirely with: + +```ts +import { describe, it, expect } from 'vitest' +import { computeAvailableSlots, getNextAvailableDateFromSlots, type ScheduleTemplate, type ScheduleException, type BookedSlot } from './availability' + +function templateFor(date: string, overrides: Partial = {}): ScheduleTemplate { + const day = new Date(`${date}T00:00:00`).getDay() + return { + id: 'default', + working_days: [day], + start_time: '09:00:00', + end_time: '13:00:00', + slot_minutes: 60, + ...overrides, + } +} + +describe('computeAvailableSlots', () => { + it('generates slots from start_time to end_time stepped by slot_minutes on a working day', () => { + const date = '2026-08-11' + const template = templateFor(date) + const slots = computeAvailableSlots(template, [], [], date) + expect(slots).toEqual([ + { date, time: '9:00 AM', available: true }, + { date, time: '10:00 AM', available: true }, + { date, time: '11:00 AM', available: true }, + { date, time: '12:00 PM', available: true }, + ]) + }) + + it('returns no slots when the date is a closed exception', () => { + const date = '2026-08-11' + const template = templateFor(date) + const exceptions: ScheduleException[] = [{ date, closed: true, note: 'Congé' }] + expect(computeAvailableSlots(template, exceptions, [], date)).toEqual([]) + }) + + it('returns no slots when the day of week is not a working day', () => { + const date = '2026-08-11' + const day = new Date(`${date}T00:00:00`).getDay() + const otherDay = (day + 1) % 7 + const template = templateFor(date, { working_days: [otherDay] }) + expect(computeAvailableSlots(template, [], [], date)).toEqual([]) + }) + + it('marks a slot unavailable when it already appears in booked_slots for that date', () => { + const date = '2026-08-11' + const template = templateFor(date) + const bookedSlots: BookedSlot[] = [{ date, time: '10:00 AM' }] + const slots = computeAvailableSlots(template, [], bookedSlots, date) + expect(slots.find((s) => s.time === '10:00 AM')?.available).toBe(false) + expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(true) + }) + + it('ignores booked_slots rows for a different date', () => { + const date = '2026-08-11' + const template = templateFor(date) + const bookedSlots: BookedSlot[] = [{ date: '2026-08-12', time: '9:00 AM' }] + const slots = computeAvailableSlots(template, [], bookedSlots, date) + expect(slots.every((s) => s.available)).toBe(true) + }) +}) + +describe('getNextAvailableDateFromSlots', () => { + it('finds the first date within the search window that has an available slot', () => { + const fromDate = '2026-08-10' + const nextDate = '2026-08-12' + const nextDay = new Date(`${nextDate}T00:00:00`).getDay() + const template: ScheduleTemplate = { + id: 'default', working_days: [nextDay], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60, + } + const result = getNextAvailableDateFromSlots(template, [], new Map(), fromDate, 14) + expect(result).toBe(nextDate) + }) + + it('returns null when no date in the window has an available slot', () => { + const template: ScheduleTemplate = { + id: 'default', working_days: [], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60, + } + expect(getNextAvailableDateFromSlots(template, [], new Map(), '2026-08-10', 14)).toBeNull() + }) + + it('respects a fully booked date by skipping it', () => { + const nextDate = '2026-08-11' + const nextDay = new Date(`${nextDate}T00:00:00`).getDay() + const template: ScheduleTemplate = { + id: 'default', working_days: [nextDay], start_time: '09:00:00', end_time: '10:00:00', slot_minutes: 60, + } + const bookedByDate = new Map([[nextDate, [{ date: nextDate, time: '9:00 AM' }]]]) + // With a 1-hour window (09:00-10:00, 60min slots) there's only one slot, and it's booked. + expect(getNextAvailableDateFromSlots(template, [], bookedByDate, '2026-08-10', 1)).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/data/availability.test.ts` +Expected: FAIL — `computeAvailableSlots`/`getNextAvailableDateFromSlots` don't exist yet. + +- [ ] **Step 3: Implement** + +Replace `src/data/availability.ts` entirely with: + +```ts +export interface TimeSlot { + date: string + time: string + available: boolean +} + +export interface ScheduleTemplate { + id: string + working_days: number[] + start_time: string + end_time: string + slot_minutes: number +} + +export interface ScheduleException { + date: string + closed: boolean + note: string | null +} + +export interface BookedSlot { + date: string + time: string +} + +function parseTimeToMinutes(time: string): number { + const [hours, minutes] = time.split(':').map(Number) + return hours * 60 + minutes +} + +function formatMinutesAsTime(totalMinutes: number): string { + const hour24 = Math.floor(totalMinutes / 60) + const minute = totalMinutes % 60 + const period = hour24 >= 12 ? 'PM' : 'AM' + const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12 + return `${hour12}:${minute.toString().padStart(2, '0')} ${period}` +} + +export function computeAvailableSlots( + template: ScheduleTemplate, + exceptions: ScheduleException[], + bookedSlots: BookedSlot[], + date: string, +): TimeSlot[] { + const exception = exceptions.find((e) => e.date === date) + if (exception?.closed) { + return [] + } + + const parsed = new Date(`${date}T00:00:00`) + if (isNaN(parsed.getTime())) { + return [] + } + + const day = parsed.getDay() + if (!template.working_days.includes(day)) { + return [] + } + + const startMinutes = parseTimeToMinutes(template.start_time) + const endMinutes = parseTimeToMinutes(template.end_time) + const bookedTimes = new Set( + bookedSlots.filter((b) => b.date === date).map((b) => b.time), + ) + + const slots: TimeSlot[] = [] + for (let m = startMinutes; m < endMinutes; m += template.slot_minutes) { + const time = formatMinutesAsTime(m) + slots.push({ date, time, available: !bookedTimes.has(time) }) + } + return slots +} + +export function getNextAvailableDateFromSlots( + template: ScheduleTemplate, + exceptions: ScheduleException[], + bookedSlotsByDate: Map, + fromDate: string, + searchWindowDays = 14, +): string | null { + const start = new Date(`${fromDate}T00:00:00`) + for (let i = 1; i <= searchWindowDays; i++) { + const candidate = new Date(start) + candidate.setDate(start.getDate() + i) + const iso = candidate.toISOString().slice(0, 10) + const slots = computeAvailableSlots(template, exceptions, bookedSlotsByDate.get(iso) ?? [], iso) + if (slots.some((s) => s.available)) { + return iso + } + } + return null +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/data/availability.test.ts` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` + +`npx vitest run` will show failures elsewhere in the suite at this point — `src/hooks/useBooking.test.ts` and any test importing `getSlotsForDate`/`getNextAvailableDate` (the old mock functions, now removed) will fail to resolve those imports. This is expected and will be fixed in later tasks (5, 9, 10) that touch those files. Confirm the failures are ONLY import-resolution errors for `getSlotsForDate`/`getNextAvailableDate` (grep the failure output for those two names) — if you see any other kind of failure, stop and investigate, don't assume it's pre-existing breakage. + +Run `npm run build` and `npm run lint` — these must be clean; `tsc` will also flag the same removed-export usages as type errors in `src/hooks/useBooking.ts`. Confirm the same thing: the only build/lint errors are for `getSlotsForDate`/`getNextAvailableDate`/`TimeSlot` (if `TimeSlot`'s shape changed in a way that breaks the existing import) in `useBooking.ts`, nothing else. + +- [ ] **Step 6: Commit** + +```bash +git add src/data/availability.ts src/data/availability.test.ts +git commit -m "feat: replace mock availability generator with real schedule computation" +``` + +--- + +### Task 3: Live products data layer + +**Files:** +- Modify: `src/data/products.ts` +- Modify: `src/data/products.test.ts` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts`; `resolveImageUrl` from `src/lib/resolveImageUrl.ts` (Task 1) +- Produces: + ```ts + export interface Product { id: string; name: string; category: 'hair-care' | 'extensions' | 'tools' | 'accessories'; price: number; image: string; description: string; inStock: boolean } + export async function fetchProducts(): Promise + export function getProductsByCategory(products: Product[], category: Product['category'] | 'all'): Product[] + ``` + Consumed by Task 7 (`Shop.tsx`), Task 8 (`Home.tsx`). + +- [ ] **Step 1: Write the failing tests** + +Replace `src/data/products.test.ts` entirely with: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const orderMock = vi.fn() +const selectMock = vi.fn(() => ({ order: orderMock })) +const fromMock = vi.fn(() => ({ select: selectMock })) +const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`) + +vi.mock('../lib/supabase', () => ({ + supabase: { from: (...args: unknown[]) => fromMock(...args) }, +})) +vi.mock('../lib/resolveImageUrl', () => ({ + resolveImageUrl: (path: string) => resolveImageUrlMock(path), +})) + +const ROW = { + id: 'edge-control', + name: 'Gel Contour Soyeux', + category: 'hair-care', + price: 12, + image: 'products/edge-control.jpg', + description: 'Tenue longue durée.', + in_stock: true, +} + +import { fetchProducts, getProductsByCategory, type Product } from './products' + +describe('fetchProducts', () => { + beforeEach(() => { + orderMock.mockReset() + selectMock.mockClear() + fromMock.mockClear() + resolveImageUrlMock.mockClear() + }) + + it('fetches products ordered by name and maps snake_case columns to the Product shape', async () => { + orderMock.mockResolvedValue({ data: [ROW], error: null }) + + const products = await fetchProducts() + + expect(fromMock).toHaveBeenCalledWith('products') + expect(orderMock).toHaveBeenCalledWith('name') + expect(resolveImageUrlMock).toHaveBeenCalledWith('products/edge-control.jpg') + expect(products).toEqual([ + { + id: 'edge-control', + name: 'Gel Contour Soyeux', + category: 'hair-care', + price: 12, + image: 'https://cdn.example/products/edge-control.jpg', + description: 'Tenue longue durée.', + inStock: true, + }, + ]) + }) + + it('throws when the fetch fails', async () => { + orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } }) + await expect(fetchProducts()).rejects.toEqual({ message: 'network error' }) + }) +}) + +describe('getProductsByCategory', () => { + const products: Product[] = [ + { id: 'a', name: 'A', category: 'hair-care', price: 1, image: '', description: '', inStock: true }, + { id: 'b', name: 'B', category: 'tools', price: 1, image: '', description: '', inStock: true }, + ] + + it('returns all products for "all"', () => { + expect(getProductsByCategory(products, 'all')).toHaveLength(2) + }) + + it('filters by category', () => { + expect(getProductsByCategory(products, 'tools')).toEqual([products[1]]) + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/data/products.test.ts` +Expected: FAIL — `fetchProducts` does not exist yet, and the old static `products` export it currently imports is gone from the test. + +- [ ] **Step 3: Implement** + +Replace `src/data/products.ts` entirely with: + +```ts +import { supabase } from '../lib/supabase' +import { resolveImageUrl } from '../lib/resolveImageUrl' + +export interface Product { + id: string + name: string + category: 'hair-care' | 'extensions' | 'tools' | 'accessories' + price: number + image: string + description: string + inStock: boolean +} + +interface ProductRow { + id: string + name: string + category: Product['category'] + price: number + image: string + description: string + in_stock: boolean +} + +export async function fetchProducts(): Promise { + const { data, error } = await supabase.from('products').select('*').order('name') + if (error) throw error + return ((data ?? []) as ProductRow[]).map((row) => ({ + id: row.id, + name: row.name, + category: row.category, + price: row.price, + image: resolveImageUrl(row.image), + description: row.description, + inStock: row.in_stock, + })) +} + +export function getProductsByCategory(products: Product[], category: Product['category'] | 'all'): Product[] { + if (category === 'all') return products + return products.filter((p) => p.category === category) +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/data/products.test.ts` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` + +Expect NEW failures now in `src/pages/Shop.tsx`, `src/pages/Home.tsx`, `src/components/ProductCard.test.tsx`, `src/components/InquiryModal.test.tsx` (anything importing the now-removed static `products` array or calling `getProductsByCategory` with the old single-argument signature) — confirm the failures are exactly these expected breakages (Shop.tsx/Home.tsx still use the old API, fixed in Tasks 7-8; ProductCard.test.tsx/InquiryModal.test.tsx only import the `Product` *type*, which still exists unchanged, so those two specific files should NOT be broken by this task — if they are, something is wrong, investigate). Do not fix Shop.tsx/Home.tsx in this task — that's Tasks 7-8. + +- [ ] **Step 6: Commit** + +```bash +git add src/data/products.ts src/data/products.test.ts +git commit -m "feat: fetch products from Supabase instead of static mock data" +``` + +--- + +### Task 4: Live services data layer + +**Files:** +- Modify: `src/data/services.ts` +- Create: `src/data/services.test.ts` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts`; `resolveImageUrl` from `src/lib/resolveImageUrl.ts` (Task 1) +- Produces: + ```ts + export interface Service { id: string; name: string; durationMinutes: number; priceFrom: number; description: string; image: string } + export async function fetchServices(): Promise + ``` + Consumed by Task 8 (`Home.tsx`), Task 9 (`Book.tsx`). + +- [ ] **Step 1: Write the failing test** + +Create `src/data/services.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const orderMock = vi.fn() +const selectMock = vi.fn(() => ({ order: orderMock })) +const fromMock = vi.fn(() => ({ select: selectMock })) +const resolveImageUrlMock = vi.fn((path: string) => `https://cdn.example/${path}`) + +vi.mock('../lib/supabase', () => ({ + supabase: { from: (...args: unknown[]) => fromMock(...args) }, +})) +vi.mock('../lib/resolveImageUrl', () => ({ + resolveImageUrl: (path: string) => resolveImageUrlMock(path), +})) + +const ROW = { + id: 'knotless', + name: 'Tresses sans nœuds', + duration_minutes: 240, + price_from: 150, + description: 'Tresses sans tension.', + image: 'styles/knotless.jpg', +} + +import { fetchServices, type Service } from './services' + +describe('fetchServices', () => { + beforeEach(() => { + orderMock.mockReset() + selectMock.mockClear() + fromMock.mockClear() + resolveImageUrlMock.mockClear() + }) + + it('fetches services ordered by name and maps snake_case columns to the Service shape', async () => { + orderMock.mockResolvedValue({ data: [ROW], error: null }) + + const services = await fetchServices() + + expect(fromMock).toHaveBeenCalledWith('services') + expect(orderMock).toHaveBeenCalledWith('name') + expect(resolveImageUrlMock).toHaveBeenCalledWith('styles/knotless.jpg') + expect(services).toEqual([ + { + id: 'knotless', + name: 'Tresses sans nœuds', + durationMinutes: 240, + priceFrom: 150, + description: 'Tresses sans tension.', + image: 'https://cdn.example/styles/knotless.jpg', + }, + ]) + }) + + it('throws when the fetch fails', async () => { + orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } }) + await expect(fetchServices()).rejects.toEqual({ message: 'network error' }) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/data/services.test.ts` +Expected: FAIL — `fetchServices` does not exist yet. + +- [ ] **Step 3: Implement** + +Replace `src/data/services.ts` entirely with: + +```ts +import { supabase } from '../lib/supabase' +import { resolveImageUrl } from '../lib/resolveImageUrl' + +export interface Service { + id: string + name: string + durationMinutes: number + priceFrom: number + description: string + image: string +} + +interface ServiceRow { + id: string + name: string + duration_minutes: number + price_from: number + description: string + image: string +} + +export async function fetchServices(): Promise { + const { data, error } = await supabase.from('services').select('*').order('name') + if (error) throw error + return ((data ?? []) as ServiceRow[]).map((row) => ({ + id: row.id, + name: row.name, + durationMinutes: row.duration_minutes, + priceFrom: row.price_from, + description: row.description, + image: resolveImageUrl(row.image), + })) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/data/services.test.ts` +Expected: PASS, 2 tests. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` + +Expect the same class of NEW failures as Task 3, now also touching anything importing the removed static `services` array (`Home.tsx`, `Book.tsx`, and any test file doing so) — confirm `ServiceCard.test.tsx` (type-only import of `Service`) is NOT broken; if it is, investigate. + +- [ ] **Step 6: Commit** + +```bash +git add src/data/services.ts src/data/services.test.ts +git commit -m "feat: fetch services from Supabase instead of static mock data" +``` + +--- + +### Task 5: Availability data-fetching hook + +**Files:** +- Create: `src/hooks/useAvailability.ts` +- Create: `src/hooks/useAvailability.test.ts` + +**Interfaces:** +- Consumes: `supabase` from `src/lib/supabase.ts`; `computeAvailableSlots`, `getNextAvailableDateFromSlots`, and the `ScheduleTemplate`/`ScheduleException`/`BookedSlot`/`TimeSlot` types from `src/data/availability.ts` (Task 2) +- Produces: + ```ts + interface UseAvailabilityResult { + loading: boolean + error: string + getSlotsForDate: (date: string) => Promise + findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise + } + export default function useAvailability(): UseAvailabilityResult + ``` + Consumed by Task 9 (`Book.tsx`). + +- [ ] **Step 1: Write the failing test** + +Create `src/hooks/useAvailability.test.ts`: + +```ts +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const templateSingleMock = vi.fn() +const exceptionsSelectMock = vi.fn() +const bookedSlotsEqMock = vi.fn() +const bookedSlotsGteMock = vi.fn() +const bookedSlotsLteMock = vi.fn() +const fromMock = vi.fn() + +vi.mock('../lib/supabase', () => ({ + supabase: { from: (table: string) => fromMock(table) }, +})) + +import useAvailability from './useAvailability' + +const TEMPLATE = { + id: 'default', + working_days: [2, 3, 4, 5, 6], + start_time: '09:00:00', + end_time: '13:00:00', + slot_minutes: 60, +} + +describe('useAvailability', () => { + beforeEach(() => { + templateSingleMock.mockReset().mockResolvedValue({ data: TEMPLATE, error: null }) + exceptionsSelectMock.mockReset().mockResolvedValue({ data: [], error: null }) + bookedSlotsLteMock.mockReset().mockResolvedValue({ data: [], error: null }) + bookedSlotsGteMock.mockReset().mockReturnValue({ lte: bookedSlotsLteMock }) + bookedSlotsEqMock.mockReset().mockResolvedValue({ data: [], error: null }) + fromMock.mockReset() + + fromMock.mockImplementation((table: string) => { + if (table === 'schedule_template') { + return { select: () => ({ eq: () => ({ single: templateSingleMock }) }) } + } + if (table === 'schedule_exceptions') { + return { select: () => exceptionsSelectMock() } + } + if (table === 'booked_slots') { + return { select: () => ({ eq: bookedSlotsEqMock, gte: bookedSlotsGteMock }) } + } + throw new Error(`unexpected table: ${table}`) + }) + }) + + it('fetches the template and exceptions once on mount', async () => { + const { result } = renderHook(() => useAvailability()) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(templateSingleMock).toHaveBeenCalled() + expect(exceptionsSelectMock).toHaveBeenCalled() + }) + + it('getSlotsForDate fetches booked_slots for that date and returns computed slots', async () => { + const day = new Date('2026-08-11T00:00:00').getDay() + templateSingleMock.mockResolvedValue({ data: { ...TEMPLATE, working_days: [day] }, error: null }) + bookedSlotsEqMock.mockResolvedValue({ data: [{ date: '2026-08-11', time: '10:00 AM' }], error: null }) + + const { result } = renderHook(() => useAvailability()) + await waitFor(() => expect(result.current.loading).toBe(false)) + + const slots = await result.current.getSlotsForDate('2026-08-11') + expect(bookedSlotsEqMock).toHaveBeenCalledWith('date', '2026-08-11') + expect(slots.find((s) => s.time === '10:00 AM')?.available).toBe(false) + expect(slots.find((s) => s.time === '9:00 AM')?.available).toBe(true) + }) + + it('findNextAvailableDate fetches a date range and returns the first available date', async () => { + const nextDate = '2026-08-12' + const nextDay = new Date(`${nextDate}T00:00:00`).getDay() + templateSingleMock.mockResolvedValue({ data: { ...TEMPLATE, working_days: [nextDay] }, error: null }) + + const { result } = renderHook(() => useAvailability()) + await waitFor(() => expect(result.current.loading).toBe(false)) + + const next = await result.current.findNextAvailableDate('2026-08-10', 14) + expect(bookedSlotsGteMock).toHaveBeenCalledWith('date', '2026-08-10') + expect(next).toBe(nextDate) + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/hooks/useAvailability.test.ts` +Expected: FAIL — `./useAvailability` does not exist yet. + +- [ ] **Step 3: Implement** + +Create `src/hooks/useAvailability.ts`: + +```ts +import { useCallback, useEffect, useState } from 'react' +import { supabase } from '../lib/supabase' +import { + computeAvailableSlots, + getNextAvailableDateFromSlots, + type TimeSlot, + type ScheduleTemplate, + type ScheduleException, + type BookedSlot, +} from '../data/availability' + +interface UseAvailabilityResult { + loading: boolean + error: string + getSlotsForDate: (date: string) => Promise + findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise +} + +export default function useAvailability(): UseAvailabilityResult { + const [template, setTemplate] = useState(null) + const [exceptions, setExceptions] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + async function load() { + const [templateResult, exceptionsResult] = await Promise.all([ + supabase.from('schedule_template').select('*').eq('id', 'default').single(), + supabase.from('schedule_exceptions').select('*'), + ]) + if (templateResult.error) { + setError(templateResult.error.message) + setLoading(false) + return + } + if (exceptionsResult.error) { + setError(exceptionsResult.error.message) + setLoading(false) + return + } + setTemplate(templateResult.data as ScheduleTemplate) + setExceptions((exceptionsResult.data ?? []) as ScheduleException[]) + setLoading(false) + } + void load() + }, []) + + const getSlotsForDate = useCallback( + async (date: string): Promise => { + if (!template) return [] + const { data, error: fetchError } = await supabase.from('booked_slots').select('*').eq('date', date) + if (fetchError) { + setError(fetchError.message) + return [] + } + return computeAvailableSlots(template, exceptions, (data ?? []) as BookedSlot[], date) + }, + [template, exceptions], + ) + + const findNextAvailableDate = useCallback( + async (fromDate: string, searchWindowDays = 14): Promise => { + if (!template) return null + const start = new Date(`${fromDate}T00:00:00`) + const end = new Date(start) + end.setDate(start.getDate() + searchWindowDays) + + const { data, error: fetchError } = await supabase + .from('booked_slots') + .select('*') + .gte('date', fromDate) + .lte('date', end.toISOString().slice(0, 10)) + if (fetchError) { + setError(fetchError.message) + return null + } + + const bookedByDate = new Map() + for (const row of (data ?? []) as BookedSlot[]) { + const list = bookedByDate.get(row.date) ?? [] + list.push(row) + bookedByDate.set(row.date, list) + } + + return getNextAvailableDateFromSlots(template, exceptions, bookedByDate, fromDate, searchWindowDays) + }, + [template, exceptions], + ) + + return { loading, error, getSlotsForDate, findNextAvailableDate } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/hooks/useAvailability.test.ts` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: no NEW failures beyond what Tasks 2-4 already introduced (Shop.tsx, Home.tsx, Book.tsx, useBooking.ts still not yet updated) — this task adds a new isolated hook nothing else depends on yet. + +- [ ] **Step 6: Commit** + +```bash +git add src/hooks/useAvailability.ts src/hooks/useAvailability.test.ts +git commit -m "feat: add useAvailability hook for real schedule/booking data" +``` + +--- + +### Task 6: Upload product/service images to Storage during seeding + +**Files:** +- Modify: `scripts/seed.ts` + +**Interfaces:** +- Consumes: nothing new from other tasks in this plan (independent of Tasks 1-5; only interacts with the live Supabase project at seed-run time) +- Produces: real image files present in the `salon-images` Storage bucket at the same relative paths already stored in `products.image`/`services.image` — consumed at runtime by `resolveImageUrl` (Task 1), verified in Task 12 + +- [ ] **Step 1: Read the current script** + +Read `scripts/seed.ts` in full before editing — it already defines `toStoragePath()` and upserts `products`/`services`/`schedule_template`. This task adds a new step that uploads the actual image files those rows reference. + +- [ ] **Step 2: Implement the image upload step** + +Modify `scripts/seed.ts`: add a `readFile` import and a new `uploadImages()` function, called from `seed()` after the products/services upserts. The full modified file: + +```ts +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { createClient } from '@supabase/supabase-js' +import { products } from '../src/data/products.js' +import { services } from '../src/data/services.js' + +const supabaseUrl = process.env.SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables before running this script.') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +function toStoragePath(image: string): string { + return image.replace(/^\//, '') +} + +async function uploadImages(imagePaths: string[]) { + for (const imagePath of imagePaths) { + const relativePath = toStoragePath(imagePath) + const filePath = path.join(process.cwd(), 'public', relativePath) + const fileBuffer = await readFile(filePath) + const { error } = await supabase.storage.from('salon-images').upload(relativePath, fileBuffer, { + upsert: true, + contentType: 'image/jpeg', + }) + if (error) throw error + } + console.log(`Uploaded ${imagePaths.length} images to the salon-images bucket.`) +} + +async function seed() { + const productRows = products.map(({ id, name, category, price, image, description, inStock }) => ({ + id, + name, + category, + price, + image: toStoragePath(image), + description, + in_stock: inStock, + })) + const { error: productsError } = await supabase.from('products').upsert(productRows) + if (productsError) throw productsError + + const serviceRows = services.map(({ id, name, durationMinutes, priceFrom, description, image }) => ({ + id, + name, + duration_minutes: durationMinutes, + price_from: priceFrom, + description, + image: toStoragePath(image), + })) + const { error: servicesError } = await supabase.from('services').upsert(serviceRows) + if (servicesError) throw servicesError + + const { error: scheduleError } = await supabase.from('schedule_template').upsert({ + id: 'default', + working_days: [2, 3, 4, 5, 6], + start_time: '09:00', + end_time: '18:00', + slot_minutes: 120, + }) + if (scheduleError) throw scheduleError + + await uploadImages([...products.map((p) => p.image), ...services.map((s) => s.image)]) + + console.log(`Seeded ${products.length} products, ${services.length} services, and the default schedule.`) +} + +seed().catch((err) => { + console.error(err) + process.exit(1) +}) +``` + +**Note:** `@supabase/supabase-js`'s storage `upload()` accepts a Node `Buffer` (it's a valid `ArrayBufferView`), but this hasn't been verified empirically against this exact SDK version in a Node script context (as opposed to a browser `File`/`Blob`, which is what `src/lib/storage.ts`'s `uploadSalonImage` uses). Verify this actually works in Step 3 below — if the upload call rejects the Buffer type, check the SDK's accepted types and adjust (e.g. wrapping in a Node-compatible `Blob` via `new Blob([fileBuffer])`, available globally in Node 18+) rather than assuming the literal code above is correct without checking. + +- [ ] **Step 3: Verify against the live project** + +Run: +```bash +SUPABASE_URL=https://ylxppnclfpnnolnjkbwv.supabase.co SUPABASE_SERVICE_ROLE_KEY= npm run seed +``` + +Expected: `Seeded 10 products, 4 services, and the default schedule.` printed, with no thrown errors from `uploadImages`. If the Buffer-type issue from Step 2's note occurs, fix it and re-run until clean. + +Then confirm the images actually landed — list the bucket contents or fetch one known path directly: +```bash +curl -sI https://ylxppnclfpnnolnjkbwv.supabase.co/storage/v1/object/public/salon-images/products/edge-control.jpg +``` +Expected: `HTTP/2 200` (or `HTTP/1.1 200`) with a `content-type: image/jpeg` header, not a 400/404. + +- [ ] **Step 4: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: no new failures (this task only modifies a script, not run by the test suite). + +- [ ] **Step 5: Commit** + +```bash +git add scripts/seed.ts +git commit -m "feat: upload product/service images to Supabase Storage during seeding" +``` + +--- + +### Task 7: Wire Shop page to live products + +**Files:** +- Modify: `src/pages/Shop.tsx` +- Modify: `src/pages/Shop.test.tsx` +- Modify: `src/App.test.tsx` + +**Interfaces:** +- Consumes: `fetchProducts`, `getProductsByCategory` from `src/data/products.ts` (Task 3) +- Produces: `Shop` component now data-driven from Supabase. No change to its exported shape (default export, no props) — nothing else in this plan directly depends on `Shop.tsx`. + +- [ ] **Step 1: Write the failing test** + +Replace `src/pages/Shop.test.tsx` entirely with: + +```tsx +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const fetchProductsMock = vi.fn() + +vi.mock('../data/products', async () => { + const actual = await vi.importActual('../data/products') + return { ...actual, fetchProducts: (...args: unknown[]) => fetchProductsMock(...args) } +}) + +const PRODUCTS = [ + { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care' as const, price: 12, image: '', description: '', inStock: true }, + { id: 'rat-tail-comb', name: 'Peigne à Tige en Acier', category: 'tools' as const, price: 6, image: '', description: '', inStock: true }, +] + +import Shop from './Shop' + +describe('Shop page', () => { + beforeEach(() => { + fetchProductsMock.mockReset().mockResolvedValue(PRODUCTS) + }) + + it('renders all products by default', async () => { + render() + for (const p of PRODUCTS) { + expect(await screen.findByText(p.name)).toBeInTheDocument() + } + }) + + it('filters products by category', async () => { + render() + await screen.findByText('Gel Contour Soyeux') + await userEvent.click(screen.getByRole('button', { name: /^outils$/i })) + expect(screen.getByText('Peigne à Tige en Acier')).toBeInTheDocument() + expect(screen.queryByText('Gel Contour Soyeux')).not.toBeInTheDocument() + }) + + it('opens the inquiry modal when a product is inquired about', async () => { + render() + const buttons = await screen.findAllByRole('button', { name: /poser une question/i }) + await userEvent.click(buttons[0]) + expect(screen.getByRole('button', { name: /envoyer la demande/i })).toBeInTheDocument() + }) + + it('shows an error message when the fetch fails', async () => { + fetchProductsMock.mockRejectedValue(new Error('network error')) + render() + expect(await screen.findByText(/network error/i)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/pages/Shop.test.tsx` +Expected: FAIL — `Shop.tsx` still imports the removed static `products` array and calls `getProductsByCategory` with the old signature. + +- [ ] **Step 3: Implement** + +Replace `src/pages/Shop.tsx` entirely with: + +```tsx +import { useEffect, useState } from 'react' +import { motion, useReducedMotion } from 'framer-motion' +import ProductCard from '../components/ProductCard' +import InquiryModal from '../components/InquiryModal' +import { fetchProducts, getProductsByCategory, type Product } from '../data/products' +import styles from './Shop.module.css' + +const CATEGORIES: (Product['category'] | 'all')[] = ['all', 'hair-care', 'extensions', 'tools', 'accessories'] + +const CATEGORY_LABELS: Record = { + all: 'Tout', + 'hair-care': 'Soins capillaires', + extensions: 'Extensions', + tools: 'Outils', + accessories: 'Accessoires', +} + +function Shop() { + const [products, setProducts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [category, setCategory] = useState('all') + const [inquiring, setInquiring] = useState(null) + const prefersReducedMotion = useReducedMotion() + + useEffect(() => { + async function load() { + try { + setProducts(await fetchProducts()) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + void load() + }, []) + + return ( +
+

Boutique

+ {loading &&

Chargement…

} + {error &&

{error}

} +
+ {CATEGORIES.map((c) => ( + + ))} +
+
+ {getProductsByCategory(products, category).map((product) => ( + + ))} +
+ {inquiring && setInquiring(null)} />} +
+ ) +} + +export default Shop +``` + +Add an `.error` class to `src/pages/Shop.module.css` if one doesn't already exist there — check the file first; if an equivalent error-text style already exists elsewhere in this module, reuse the existing pattern from the admin CSS modules (`color: #a33;`) rather than guessing. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/pages/Shop.test.tsx` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Fix the Shop assertion in App.test.tsx** + +Read `src/App.test.tsx`. It currently renders the full `` tree with no Supabase mock at all, which will now fail for every page component that fetches data (Shop, Home, Book — Home's test already uses `await screen.findByText`, tolerant of async rendering, but has no mock so the real `supabase` client will attempt a real network call in the test environment, which either hangs, errors, or (if `VITE_SUPABASE_URL` happens to be set in the test env) hits the live project — none of which is what a unit test should do). + +Add a `supabase` mock at the top of `src/App.test.tsx` so every page's fetch resolves to an empty, harmless result rather than touching the network, and update the Shop assertion to tolerate the now-async render: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import App from './App' + +vi.mock('./lib/supabase', () => ({ + supabase: { + from: () => ({ + select: () => ({ + order: () => Promise.resolve({ data: [], error: null }), + eq: () => ({ single: () => Promise.resolve({ data: null, error: null }) }), + }), + }), + }, +})) + +describe('App routing', () => { + it('renders the Home page at /', async () => { + render() + expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument() + }) + + it('renders the Shop page at /shop', async () => { + render() + expect(await screen.findByRole('heading', { name: /boutique/i })).toBeInTheDocument() + }) + + it('renders the Book page at /book', () => { + render() + expect(screen.getByRole('heading', { name: /réserver un rendez-vous/i })).toBeInTheDocument() + }) + + it('renders the Rules page at /rules', () => { + render() + expect(screen.getByRole('heading', { name: /règlements/i })).toBeInTheDocument() + }) +}) +``` + +(The Book assertion here still works synchronously since `Book.tsx`'s page heading itself doesn't depend on the fetch completing — only the service list below it does. Task 9 will revisit this file again if Book's heading-level rendering changes in a way that needs `findByRole` too; verify in that task rather than assuming this file is fully done after this step.) + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `npx vitest run src/App.test.tsx` +Expected: PASS, 4 tests. + +- [ ] **Step 7: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: `Home.tsx`/`Book.tsx` still show pre-existing failures from Tasks 3-4 (not yet fixed — that's Tasks 8-9). Confirm no NEW failures beyond what already existed, and that `Shop.test.tsx`/`App.test.tsx` are both fully green now. + +- [ ] **Step 8: Commit** + +```bash +git add src/pages/Shop.tsx src/pages/Shop.test.tsx src/pages/Shop.module.css src/App.test.tsx +git commit -m "feat: wire Shop page to live Supabase product data" +``` + +--- + +### Task 8: Wire Home page to live products and services + +**Files:** +- Modify: `src/pages/Home.tsx` +- Modify: `src/pages/Home.test.tsx` + +**Interfaces:** +- Consumes: `fetchProducts` from `src/data/products.ts` (Task 3), `fetchServices` from `src/data/services.ts` (Task 4) +- Produces: `Home` component now data-driven from Supabase. No change to its exported shape. + +- [ ] **Step 1: Write the failing test** + +Replace `src/pages/Home.test.tsx` entirely with: + +```tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const fetchProductsMock = vi.fn() +const fetchServicesMock = vi.fn() + +vi.mock('../data/products', async () => { + const actual = await vi.importActual('../data/products') + return { ...actual, fetchProducts: (...args: unknown[]) => fetchProductsMock(...args) } +}) +vi.mock('../data/services', async () => { + const actual = await vi.importActual('../data/services') + return { ...actual, fetchServices: (...args: unknown[]) => fetchServicesMock(...args) } +}) + +const PRODUCTS = [ + { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care' as const, price: 12, image: '', description: '', inStock: true }, +] +const SERVICES = [ + { id: 'knotless', name: 'Tresses sans nœuds', durationMinutes: 240, priceFrom: 150, description: '', image: '' }, +] + +import Home from './Home' +import Book from './Book' + +function renderHome() { + return render( + + + } /> + } /> + + , + ) +} + +describe('Home page', () => { + beforeEach(() => { + fetchProductsMock.mockReset().mockResolvedValue(PRODUCTS) + fetchServicesMock.mockReset().mockResolvedValue(SERVICES) + }) + + it('renders the hero headline and all signature styles', async () => { + renderHome() + expect(screen.getByText(/vos cheveux, votre couronne/i)).toBeInTheDocument() + for (const service of SERVICES) { + expect(await screen.findByText(service.name)).toBeInTheDocument() + } + }) + + it('renders shop highlights and a link to the rules page', async () => { + renderHome() + await screen.findByText('Gel Contour Soyeux') + expect(screen.getByRole('link', { name: /voir toute la boutique/i })).toHaveAttribute('href', '/shop') + expect(screen.getByRole('link', { name: /règlements du salon/i })).toHaveAttribute('href', '/rules') + }) + + it('navigates to /book with the chosen service preselected when a signature style is clicked', async () => { + renderHome() + const button = await screen.findByRole('button', { name: new RegExp(SERVICES[0].name) }) + await userEvent.click(button) + expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument() + }) + + it('renders the About Mira portrait', () => { + renderHome() + expect(screen.getByRole('img', { name: /mira/i })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/pages/Home.test.tsx` +Expected: FAIL — `Home.tsx` still imports the removed static `products`/`services` arrays. + +- [ ] **Step 3: Implement** + +Modify `src/pages/Home.tsx`. Replace the static imports and add fetch state. Read the current file first (it has the About/testimonials/banner sections which stay unchanged — testimonials remain the static `TESTIMONIALS` array, out of scope per the design spec). Replace only the products/services sourcing: + +```tsx +import { useEffect, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { motion, useScroll, useTransform, useReducedMotion } from 'framer-motion' +import Hero3DScene from '../components/Hero3DScene' +import ScrollReveal from '../components/ScrollReveal' +import ServiceCard from '../components/ServiceCard' +import ProductCard from '../components/ProductCard' +import { fetchServices, type Service } from '../data/services' +import { fetchProducts, type Product } from '../data/products' +import styles from './Home.module.css' + +const TESTIMONIALS = [ + { quote: 'Mira m\'a fait sentir comme une reine sur son fauteuil. Mes tresses ont tenu 8 semaines !', author: 'Amara T.', avatar: '/testimonials/amara.jpg' }, + { quote: 'La tresseuse la plus douce et la plus précise que j\'aie jamais consultée.', author: 'Keisha R.', avatar: '/testimonials/keisha.jpg' }, + { quote: 'La réservation était si simple, et le résultat parle de lui-même.', author: 'Nadia F.', avatar: '/testimonials/nadia.jpg' }, +] + +function Home() { + const navigate = useNavigate() + const { scrollYProgress } = useScroll() + const prefersReducedMotion = useReducedMotion() + const portraitY = useTransform(scrollYProgress, [0, 1], prefersReducedMotion ? [0, 0] : [0, -40]) + + const [services, setServices] = useState([]) + const [products, setProducts] = useState([]) + const [error, setError] = useState('') + + useEffect(() => { + async function load() { + try { + const [servicesResult, productsResult] = await Promise.all([fetchServices(), fetchProducts()]) + setServices(servicesResult) + setProducts(productsResult) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + void load() + }, []) + + return ( +
+
+ +

Vos cheveux, votre couronne

+

Coiffures protectrices par Mira

+
+ Réserver maintenant + Voir les produits +
+
+ + {error &&

{error}

} + + +
+
+ +
+

À propos de Mira

+

+ Avec plus d'une décennie d'expérience derrière le fauteuil, Mira aborde chaque installation + comme une œuvre d'art — douce pour le cuir chevelu, précise à la racine, et achevée pour une + couronne que vous aurez envie d'afficher. +

+
+
+
+
+ + +
+

Styles Signature

+
+ {services.map((service) => ( + navigate('/book', { state: { serviceId } })} + /> + ))} +
+
+
+ + +
+

Sélection boutique

+
+ {products.filter((p) => p.inStock).slice(0, 3).map((product) => ( + navigate('/shop')} /> + ))} +
+

Voir toute la boutique

+
+
+ + +
+

Avis de nos clientes

+
+ {TESTIMONIALS.map((t) => ( +
+ {t.author} +

“{t.quote}”

+ {t.author} +
+ ))} +
+
+
+ +
+

Avant de réserver

+

+ Merci de prendre un instant pour lire les règlements du salon avant de réserver votre rendez-vous. +

+
+
+ ) +} + +export default Home +``` + +(The literal French copy above is transcribed from the already-translated file — read the current `Home.tsx` before replacing it to confirm you're preserving every already-translated string exactly, not reverting any of it. If anything above doesn't match what's currently in the file, keep what's currently there and only change the data-fetching parts.) + +Add an `.error` class to `src/pages/Home.module.css` matching the same pattern used in Shop's (Task 7). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/pages/Home.test.tsx` +Expected: PASS, 4 tests. + +- [ ] **Step 5: Run full local verification** + +Run: `npx vitest run && npm run build && npm run lint` +Expected: `Book.tsx`/`useBooking.ts` still show pre-existing failures (fixed in Tasks 9-10). Confirm no new failures beyond that, and `Home.test.tsx` is fully green. + +- [ ] **Step 6: Commit** + +```bash +git add src/pages/Home.tsx src/pages/Home.test.tsx src/pages/Home.module.css +git commit -m "feat: wire Home page to live Supabase product and service data" +``` + +--- + +### Task 9: Wire Book page's service list and availability to live data + +**Files:** +- Modify: `src/pages/Book.tsx` +- Modify: `src/pages/Book.test.tsx` +- Modify: `src/App.test.tsx` + +**Interfaces:** +- Consumes: `fetchServices` from `src/data/services.ts` (Task 4); `useAvailability` from `src/hooks/useAvailability.ts` (Task 5) +- Produces: `Book` component's service-selection and date/slot steps now data-driven from Supabase. The contact/confirm steps and `useBooking` integration are untouched here — Task 10 makes `useBooking.submit()` itself persist to the database. + +- [ ] **Step 1: Write the failing tests** + +Replace `src/pages/Book.test.tsx` entirely with: + +```tsx +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Routes, Route } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const fetchServicesMock = vi.fn() +const getSlotsForDateMock = vi.fn() +const findNextAvailableDateMock = vi.fn() + +vi.mock('../data/services', async () => { + const actual = await vi.importActual('../data/services') + return { ...actual, fetchServices: (...args: unknown[]) => fetchServicesMock(...args) } +}) +vi.mock('../hooks/useAvailability', () => ({ + default: () => ({ + loading: false, + error: '', + getSlotsForDate: (...args: unknown[]) => getSlotsForDateMock(...args), + findNextAvailableDate: (...args: unknown[]) => findNextAvailableDateMock(...args), + }), +})) + +const SERVICES = [ + { id: 'knotless', name: 'Tresses sans nœuds', durationMinutes: 240, priceFrom: 150, description: '', image: '' }, + { id: 'cornrows', name: 'Nattes Collées', durationMinutes: 120, priceFrom: 90, description: '', image: '' }, +] + +import Book from './Book' + +function renderBook(initialEntries: { pathname: string; state?: unknown }[]) { + return render( + + + } /> + + , + ) +} + +describe('Book page', () => { + beforeEach(() => { + fetchServicesMock.mockReset().mockResolvedValue(SERVICES) + getSlotsForDateMock.mockReset().mockResolvedValue([ + { date: '2026-08-11', time: '9:00 AM', available: true }, + { date: '2026-08-11', time: '11:00 AM', available: true }, + ]) + findNextAvailableDateMock.mockReset().mockResolvedValue('2026-08-12') + }) + + it('starts on the service step and advances to date after selecting a service', async () => { + renderBook([{ pathname: '/book' }]) + expect(await screen.findByText(/tresses sans nœuds/i)).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /tresses sans nœuds/i })) + expect(await screen.findByLabelText(/choisissez une date/i)).toBeInTheDocument() + }) + + it('preselects the service from route state and skips to the date step', () => { + renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }]) + expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument() + }) + + it('fetches and displays available slots for the chosen date', async () => { + renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }]) + await userEvent.type(screen.getByLabelText(/choisissez une date/i), '2026-08-11') + await waitFor(() => expect(getSlotsForDateMock).toHaveBeenCalledWith('2026-08-11')) + expect(await screen.findByRole('button', { name: '9:00 AM' })).toBeInTheDocument() + }) + + it('shows a friendly empty state with the next available date when a chosen date has none', async () => { + getSlotsForDateMock.mockResolvedValue([]) + renderBook([{ pathname: '/book', state: { serviceId: 'cornrows' } }]) + await userEvent.type(screen.getByLabelText(/choisissez une date/i), '2026-08-09') + expect(await screen.findByText(/aucun créneau disponible/i)).toBeInTheDocument() + expect(await screen.findByRole('button', { name: /essayez .* à la place/i })).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/pages/Book.test.tsx` +Expected: FAIL — `Book.tsx` still imports the removed static `services` array and the old sync `getSlotsForDate`/`getNextAvailableDate` from `../data/availability`. + +- [ ] **Step 3: Implement** + +Replace `src/pages/Book.tsx` entirely with: + +```tsx +import { useLocation } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import useBooking from '../hooks/useBooking' +import useAvailability from '../hooks/useAvailability' +import ServiceCard from '../components/ServiceCard' +import StepProgress from '../components/StepProgress' +import { fetchServices, type Service } from '../data/services' +import type { TimeSlot } from '../data/availability' +import styles from './Book.module.css' + +function Book() { + const location = useLocation() + const preselectedServiceId = (location.state as { serviceId?: string } | null)?.serviceId + const booking = useBooking(preselectedServiceId) + const availability = useAvailability() + const [dateInput, setDateInput] = useState('') + const [services, setServices] = useState([]) + const [servicesError, setServicesError] = useState('') + const [slots, setSlots] = useState([]) + const [nextAvailable, setNextAvailable] = useState(null) + const prefersReducedMotion = useReducedMotion() + + useEffect(() => { + async function load() { + try { + setServices(await fetchServices()) + } catch (err) { + setServicesError(err instanceof Error ? err.message : String(err)) + } + } + void load() + }, []) + + useEffect(() => { + if (!dateInput || availability.loading) { + setSlots([]) + setNextAvailable(null) + return + } + let cancelled = false + setNextAvailable(null) + void availability.getSlotsForDate(dateInput).then((result) => { + if (cancelled) return + setSlots(result) + if (!result.some((s) => s.available)) { + void availability.findNextAvailableDate(dateInput).then((next) => { + if (!cancelled) setNextAvailable(next) + }) + } + }) + return () => { + cancelled = true + } + }, [dateInput, booking.state.step, availability.loading, availability.getSlotsForDate, availability.findNextAvailableDate]) + + const availableSlots = slots.filter((s) => s.available) + const selectedService = services.find((s) => s.id === booking.state.serviceId) + + return ( +
+

Réserver un rendez-vous

+ + {servicesError &&

{servicesError}

} + {availability.error &&

{availability.error}

} + + + + {booking.state.step === 'confirmed' && ( +

+ {selectedService?.name} le {booking.state.date} à {booking.state.slot?.time}. + Mira vous attend. +

+ )} + + {booking.state.step === 'service' && ( +
+ {services.map((service) => ( + + ))} +
+ )} + + {(booking.state.step === 'date' || booking.state.step === 'slot') && ( + <> + {booking.submitError &&

{booking.submitError}

} +
+ + { + setDateInput(e.target.value) + booking.selectDate(e.target.value) + }} + /> +
+ + {dateInput && availableSlots.length === 0 && ( +
+

Aucun créneau disponible à cette date.

+ {nextAvailable && ( + + )} +
+ )} + + {availableSlots.length > 0 && ( +
+ {availableSlots.map((slot) => ( + + ))} +
+ )} + + )} + + {booking.state.step === 'contact' && ( +
{ + e.preventDefault() + void booking.submit() + }} + > +
+ + booking.updateContact({ name: e.target.value })} + /> + {booking.errors.name && {booking.errors.name}} +
+
+ + booking.updateContact({ email: e.target.value })} + /> + {booking.errors.email && {booking.errors.email}} +
+
+ + booking.updateContact({ phone: e.target.value })} + /> + {booking.errors.phone && {booking.errors.phone}} +
+
+ +