Files
braidsbymira/docs/superpowers/plans/2026-08-12-phase2c-public-site-live-data.md
belviskhoremk 063874b60c docs: proactively fix the vi.fn spread-args TS2556 pattern in remaining tasks
Task 3's implementer independently hit and fixed this exact bug
(a vi.fn() given a zero-arg implementation, then invoked via a spread
of unknown[] args inside a vi.mock factory, fails tsc -b with TS2556).
Applied the same fix (explicit vi.fn<...> generic type) to the other
occurrences of this pattern still written the old way in this plan's
Task 3 and Task 4 sections, so those implementers don't have to
rediscover it from scratch. Task 5, 10, and 11's occurrences were
checked and found to already use safe patterns (single literal-arg
calls, or bare vi.fn() with no implementation) that don't trigger this.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
2026-08-12 20:11:15 +02:00

95 KiB

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:

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:

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
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:

    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<string, BookedSlot[]>, 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:

import { describe, it, expect } from 'vitest'
import { computeAvailableSlots, getNextAvailableDateFromSlots, type ScheduleTemplate, type ScheduleException, type BookedSlot } from './availability'

function templateFor(date: string, overrides: Partial<ScheduleTemplate> = {}): 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<string, BookedSlot[]>([[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:

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<string, BookedSlot[]>,
  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
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:

    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<Product[]>
    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:

import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(...args: unknown[]) => { select: typeof selectMock }>(() => ({ 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<Product[]>([
      {
        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:

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<Product[]> {
  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
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:

    export interface Service { id: string; name: string; durationMinutes: number; priceFrom: number; description: string; image: string }
    export async function fetchServices(): Promise<Service[]>
    

    Consumed by Task 8 (Home.tsx), Task 9 (Book.tsx).

  • Step 1: Write the failing test

Create src/data/services.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(...args: unknown[]) => { select: typeof selectMock }>(() => ({ 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<Service[]>([
      {
        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:

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<Service[]> {
  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
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:

    interface UseAvailabilityResult {
      loading: boolean
      error: string
      getSlotsForDate: (date: string) => Promise<TimeSlot[]>
      findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise<string | null>
    }
    export default function useAvailability(): UseAvailabilityResult
    

    Consumed by Task 9 (Book.tsx).

  • Step 1: Write the failing test

Create src/hooks/useAvailability.test.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:

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<TimeSlot[]>
  findNextAvailableDate: (fromDate: string, searchWindowDays?: number) => Promise<string | null>
}

export default function useAvailability(): UseAvailabilityResult {
  const [template, setTemplate] = useState<ScheduleTemplate | null>(null)
  const [exceptions, setExceptions] = useState<ScheduleException[]>([])
  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<TimeSlot[]> => {
      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<string | null> => {
      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<string, BookedSlot[]>()
      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
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:

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:

SUPABASE_URL=https://ylxppnclfpnnolnjkbwv.supabase.co SUPABASE_SERVICE_ROLE_KEY=<the live project's service role key, from Supabase dashboard → Settings → API> 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:

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
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:

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const orderMock = vi.fn()
const selectMock = vi.fn(() => ({ order: orderMock }))
const fromMock = vi.fn<(...args: unknown[]) => { select: typeof selectMock }>(() => ({ select: selectMock }))

vi.mock('../lib/supabase', () => ({
  supabase: { from: (...args: unknown[]) => fromMock(...args) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => path,
}))

const PRODUCT_ROWS = [
  { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true },
  { id: 'rat-tail-comb', name: 'Peigne à Tige en Acier', category: 'tools', price: 6, image: '', description: '', in_stock: true },
]

import Shop from './Shop'

describe('Shop page', () => {
  beforeEach(() => {
    orderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null })
    fromMock.mockClear()
  })

  it('renders all products by default', async () => {
    render(<Shop />)
    for (const p of PRODUCT_ROWS) {
      expect(await screen.findByText(p.name)).toBeInTheDocument()
    }
  })

  it('filters products by category', async () => {
    render(<Shop />)
    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(<Shop />)
    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 () => {
    orderMock.mockResolvedValue({ data: null, error: { message: 'network error' } })
    render(<Shop />)
    expect(await screen.findByText(/network error/i)).toBeInTheDocument()
  })
})

Note on this mocking approach: mocking ../lib/supabase (rather than ../data/products via vi.importActual) is deliberate — vi.importActual would execute the real src/data/products.ts, which imports the real src/lib/supabase.ts, which constructs a real Supabase client at module-load time via createClient(import.meta.env.VITE_SUPABASE_URL, ...). In a test environment with no .env file (as this worktree has none), that throws Error: supabaseUrl is required before the test even runs. Mocking ../lib/supabase directly avoids this entirely while still exercising the real fetchProducts/getProductsByCategory implementations — this is the same pattern Task 3's own products.test.ts already uses successfully.

  • 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:

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<Product['category'] | 'all', string> = {
  all: 'Tout',
  'hair-care': 'Soins capillaires',
  extensions: 'Extensions',
  tools: 'Outils',
  accessories: 'Accessoires',
}

function Shop() {
  const [products, setProducts] = useState<Product[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState('')
  const [category, setCategory] = useState<Product['category'] | 'all'>('all')
  const [inquiring, setInquiring] = useState<Product | null>(null)
  const prefersReducedMotion = useReducedMotion()

  useEffect(() => {
    async function load() {
      try {
        setProducts(await fetchProducts())
      } catch (err) {
        setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
      } finally {
        setLoading(false)
      }
    }
    void load()
  }, [])

  return (
    <div className={styles.page}>
      <h1>Boutique</h1>
      {loading && <p>Chargement…</p>}
      {error && <p className={styles.error}>{error}</p>}
      <div className={styles.filters}>
        {CATEGORIES.map((c) => (
          <button
            key={c}
            type="button"
            className={styles.filterButton}
            onClick={() => setCategory(c)}
          >
            {c === category && (
              <motion.span
                layoutId="active-filter-pill"
                className={styles.activePill}
                transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 350, damping: 30 }}
              />
            )}
            <span className={styles.filterLabel}>{CATEGORY_LABELS[c]}</span>
          </button>
        ))}
      </div>
      <div className={styles.grid}>
        {getProductsByCategory(products, category).map((product) => (
          <ProductCard key={product.id} product={product} onInquire={setInquiring} />
        ))}
      </div>
      {inquiring && <InquiryModal product={inquiring} onClose={() => setInquiring(null)} />}
    </div>
  )
}

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 <App> 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:

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(<App initialEntries={['/']} />)
    expect(await screen.findByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
  })

  it('renders the Shop page at /shop', async () => {
    render(<App initialEntries={['/shop']} />)
    expect(await screen.findByRole('heading', { name: /boutique/i })).toBeInTheDocument()
  })

  it('renders the Book page at /book', () => {
    render(<App initialEntries={['/book']} />)
    expect(screen.getByRole('heading', { name: /réserver un rendez-vous/i })).toBeInTheDocument()
  })

  it('renders the Rules page at /rules', () => {
    render(<App initialEntries={['/rules']} />)
    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
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:

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 productsOrderMock = vi.fn()
const servicesOrderMock = vi.fn()
const fromMock = vi.fn<(table: string) => { select: () => { order: typeof productsOrderMock } }>((table: string) => {
  if (table === 'products') return { select: () => ({ order: productsOrderMock }) }
  if (table === 'services') return { select: () => ({ order: servicesOrderMock }) }
  throw new Error(`unexpected table: ${table}`)
})

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => path,
}))

const PRODUCT_ROWS = [
  { id: 'edge-control', name: 'Gel Contour Soyeux', category: 'hair-care', price: 12, image: '', description: '', in_stock: true },
]
const SERVICE_ROWS = [
  { id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' },
]

import Home from './Home'
import Book from './Book'

function renderHome() {
  return render(
    <MemoryRouter initialEntries={['/']}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/book" element={<Book />} />
      </Routes>
    </MemoryRouter>,
  )
}

describe('Home page', () => {
  beforeEach(() => {
    productsOrderMock.mockReset().mockResolvedValue({ data: PRODUCT_ROWS, error: null })
    servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null })
    fromMock.mockClear()
  })

  it('renders the hero headline and all signature styles', async () => {
    renderHome()
    expect(screen.getByText(/vos cheveux, votre couronne/i)).toBeInTheDocument()
    for (const service of SERVICE_ROWS) {
      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(SERVICE_ROWS[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()
  })
})

Note on this mocking approach and on rendering <Book> in this file: as in Task 7, ../lib/supabase is mocked directly rather than using vi.importActual on the data modules, for the same "supabaseUrl is required" reason. Separately: this test file also renders <Book> at /book (for the navigation test), and at this point in the plan Book.tsx has NOT yet been updated by Task 9 — it still has Task 2's breakage (getSlotsForDate/getNextAvailableDate no longer exported from ../data/availability) and Task 4's breakage (services no longer exported from ../data/services). This is safe for this specific test only because: (a) the navigation test passes a preselected serviceId via route state, which makes useBooking start at the 'date' step directly — Book.tsx's 'service' step branch, which maps over the now-undefined services array, is never rendered; and (b) getSlotsForDate is only invoked when dateInput is truthy, and this test never types into the date field, so the broken import is never called as a function. Confirm this reasoning holds when you run the test — if Book.tsx throws anyway, it means the assumption above doesn't hold for the current state of the file, and you should stop and report rather than working around it, since that would indicate a real ordering problem in this plan.

  • 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:

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<Service[]>([])
  const [products, setProducts] = useState<Product[]>([])
  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 && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
      }
    }
    void load()
  }, [])

  return (
    <div>
      <section className={styles.hero}>
        <Hero3DScene />
        <h1 className={styles.headline}>Vos cheveux, votre couronne</h1>
        <p className={styles.tagline}>Coiffures protectrices par Mira</p>
        <div className={styles.ctas}>
          <Link to="/book">Réserver maintenant</Link>
          <Link to="/shop">Voir les produits</Link>
        </div>
      </section>

      {error && <p className={styles.error}>{error}</p>}

      <ScrollReveal>
        <section className={styles.altSection}>
          <div className={styles.aboutGrid}>
            <motion.img
              src="/about/mira-portrait.jpg"
              alt="Mira, propriétaire et coiffeuse chez Braids by Mira"
              className={styles.portrait}
              style={{ y: portraitY }}
            />
            <div>
              <h2>À propos de Mira</h2>
              <p>
                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.
              </p>
            </div>
          </div>
        </section>
      </ScrollReveal>

      <ScrollReveal>
        <section className={styles.section}>
          <h2>Styles Signature</h2>
          <div className={styles.row}>
            {services.map((service) => (
              <ServiceCard
                key={service.id}
                service={service}
                onSelect={(serviceId) => navigate('/book', { state: { serviceId } })}
              />
            ))}
          </div>
        </section>
      </ScrollReveal>

      <ScrollReveal>
        <section className={styles.altSection}>
          <h2>Sélection boutique</h2>
          <div className={styles.row}>
            {products.filter((p) => p.inStock).slice(0, 3).map((product) => (
              <ProductCard key={product.id} product={product} onInquire={() => navigate('/shop')} />
            ))}
          </div>
          <p><Link to="/shop">Voir toute la boutique</Link></p>
        </section>
      </ScrollReveal>

      <ScrollReveal>
        <section className={styles.section}>
          <h2>Avis de nos clientes</h2>
          <div className={styles.row}>
            {TESTIMONIALS.map((t) => (
              <blockquote key={t.author} className={styles.testimonial}>
                <img src={t.avatar} alt={t.author} className={styles.avatar} />
                <p>&ldquo;{t.quote}&rdquo;</p>
                <cite>{t.author}</cite>
              </blockquote>
            ))}
          </div>
        </section>
      </ScrollReveal>

      <section className={styles.banner}>
        <h2>Avant de réserver</h2>
        <p>
          Merci de prendre un instant pour lire les <Link to="/rules">règlements du salon</Link> avant de réserver votre rendez-vous.
        </p>
      </section>
    </div>
  )
}

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
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:

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 servicesOrderMock = vi.fn()
const fromMock = vi.fn<(table: string) => { select: () => { order: typeof servicesOrderMock } }>((table: string) => {
  if (table === 'services') return { select: () => ({ order: servicesOrderMock }) }
  throw new Error(`unexpected table: ${table}`)
})
const getSlotsForDateMock = vi.fn()
const findNextAvailableDateMock = vi.fn()

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
  resolveImageUrl: (path: string) => path,
}))
vi.mock('../hooks/useAvailability', () => ({
  default: () => ({
    loading: false,
    error: '',
    getSlotsForDate: (...args: unknown[]) => getSlotsForDateMock(...args),
    findNextAvailableDate: (...args: unknown[]) => findNextAvailableDateMock(...args),
  }),
}))

const SERVICE_ROWS = [
  { id: 'knotless', name: 'Tresses sans nœuds', duration_minutes: 240, price_from: 150, description: '', image: '' },
  { id: 'cornrows', name: 'Nattes Collées', duration_minutes: 120, price_from: 90, description: '', image: '' },
]

import Book from './Book'

function renderBook(initialEntries: { pathname: string; state?: unknown }[]) {
  return render(
    <MemoryRouter initialEntries={initialEntries}>
      <Routes>
        <Route path="/book" element={<Book />} />
      </Routes>
    </MemoryRouter>,
  )
}

describe('Book page', () => {
  beforeEach(() => {
    servicesOrderMock.mockReset().mockResolvedValue({ data: SERVICE_ROWS, error: null })
    fromMock.mockClear()
    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:

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<Service[]>([])
  const [servicesError, setServicesError] = useState('')
  const [slots, setSlots] = useState<TimeSlot[]>([])
  const [nextAvailable, setNextAvailable] = useState<string | null>(null)
  const prefersReducedMotion = useReducedMotion()

  useEffect(() => {
    async function load() {
      try {
        setServices(await fetchServices())
      } catch (err) {
        setServicesError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).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 (
    <div className={styles.page}>
      <h1>Réserver un rendez-vous</h1>
      <StepProgress currentStep={booking.state.step} />
      {servicesError && <p className={styles.error}>{servicesError}</p>}
      {availability.error && <p className={styles.error}>{availability.error}</p>}

      <AnimatePresence mode="wait">
        <motion.div
          key={booking.state.step}
          initial={{ opacity: 0, x: 12 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -12 }}
          transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.25 }}
        >
          {booking.state.step === 'confirmed' && (
            <p>
              {selectedService?.name} le {booking.state.date} à {booking.state.slot?.time}.
              Mira vous attend.
            </p>
          )}

          {booking.state.step === 'service' && (
            <div className={styles.services}>
              {services.map((service) => (
                <ServiceCard key={service.id} service={service} onSelect={booking.selectService} />
              ))}
            </div>
          )}

          {(booking.state.step === 'date' || booking.state.step === 'slot') && (
            <>
              {booking.submitError && <p className={styles.error}>{booking.submitError}</p>}
              <div className={styles.field}>
                <label htmlFor="booking-date">Choisissez une date</label>
                <input
                  id="booking-date"
                  type="date"
                  value={dateInput}
                  min={new Date().toISOString().slice(0, 10)}
                  onChange={(e) => {
                    setDateInput(e.target.value)
                    booking.selectDate(e.target.value)
                  }}
                />
              </div>

              {dateInput && availableSlots.length === 0 && (
                <div>
                  <p>Aucun créneau disponible à cette date.</p>
                  {nextAvailable && (
                    <button
                      type="button"
                      onClick={() => {
                        setDateInput(nextAvailable)
                        booking.selectDate(nextAvailable)
                      }}
                    >
                      Essayez le {nextAvailable} à la place
                    </button>
                  )}
                </div>
              )}

              {availableSlots.length > 0 && (
                <div className={styles.slots}>
                  {availableSlots.map((slot) => (
                    <button key={slot.time} type="button" onClick={() => booking.selectSlot(slot)}>
                      {slot.time}
                    </button>
                  ))}
                </div>
              )}
            </>
          )}

          {booking.state.step === 'contact' && (
            <form
              onSubmit={(e) => {
                e.preventDefault()
                void booking.submit()
              }}
            >
              <div className={styles.field}>
                <label htmlFor="contact-name">Nom</label>
                <input
                  id="contact-name"
                  value={booking.state.contact.name}
                  onChange={(e) => booking.updateContact({ name: e.target.value })}
                />
                {booking.errors.name && <span className={styles.error}>{booking.errors.name}</span>}
              </div>
              <div className={styles.field}>
                <label htmlFor="contact-email">Courriel</label>
                <input
                  id="contact-email"
                  value={booking.state.contact.email}
                  onChange={(e) => booking.updateContact({ email: e.target.value })}
                />
                {booking.errors.email && <span className={styles.error}>{booking.errors.email}</span>}
              </div>
              <div className={styles.field}>
                <label htmlFor="contact-phone">Téléphone</label>
                <input
                  id="contact-phone"
                  value={booking.state.contact.phone}
                  onChange={(e) => booking.updateContact({ phone: e.target.value })}
                />
                {booking.errors.phone && <span className={styles.error}>{booking.errors.phone}</span>}
              </div>
              <div className={styles.field}>
                <label htmlFor="contact-notes">Notes</label>
                <textarea
                  id="contact-notes"
                  value={booking.state.contact.notes}
                  onChange={(e) => booking.updateContact({ notes: e.target.value })}
                />
              </div>
              <button type="submit">Confirmer la réservation</button>
            </form>
          )}
        </motion.div>
      </AnimatePresence>
    </div>
  )
}

export default Book

Note: booking.submitError is referenced here but doesn't exist on useBooking's return value yet — that's added in Task 10. This task's own tests (Step 1 above) don't exercise the submit/contact flow at all (that's covered by useBooking.test.ts and will be covered end-to-end once Task 10 lands), so booking.submitError will simply be undefined and render nothing extra until Task 10. Confirm this doesn't cause a TypeScript error in this task (it won't, since Task 10 hasn't run yet in a real execution — but if you're implementing Tasks 9 and 10 out of order for any reason, watch for this ordering dependency). If npm run build's tsc step fails specifically because submitError doesn't exist on the current useBooking return type, that confirms the ordering dependency — note it and move to Task 10 next; don't work around it by inventing a different property name here.

  • Step 4: Run the tests to verify they pass

Run: npx vitest run src/pages/Book.test.tsx Expected: PASS, 4 tests. (booking.submitError being undefined at this point is fine for these specific tests since none of them reach the contact/submit step.)

  • Step 5: Fix the Book assertion in App.test.tsx if needed

Re-run npx vitest run src/App.test.tsx. The Book heading render is synchronous (the <h1> doesn't depend on the services fetch), so the existing getByRole('heading', ...) assertion from Task 7's edit should still pass unchanged. Confirm this — if it fails, change that one assertion to findByRole matching the same pattern used for Shop.

  • Step 6: Run full local verification

Run: npx vitest run && npm run build && npm run lint

Expect tsc/lint failures specifically about booking.submitError not existing on useBooking's return type (per the Step 3 note) — confirm this is the ONLY remaining failure class, then proceed to Task 10 without trying to fix it here.

  • Step 7: Commit
git add src/pages/Book.tsx src/pages/Book.test.tsx src/App.test.tsx
git commit -m "feat: wire Book page's service list and slot picker to live Supabase data"

Task 10: Persist real bookings

Files:

  • Modify: src/hooks/useBooking.ts
  • Modify: src/hooks/useBooking.test.ts

Interfaces:

  • Consumes: supabase from src/lib/supabase.ts

  • Produces: useBooking()'s returned object gains submitError: string; submit() becomes async (): Promise<boolean> (was previously synchronous). Consumed by Book.tsx (Task 9, already wired to reference booking.submitError).

  • Step 1: Write the failing tests

Replace src/hooks/useBooking.test.ts entirely with:

import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const insertMock = vi.fn()
const fromMock = vi.fn(() => ({ insert: insertMock }))

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))

import useBooking from './useBooking'

describe('useBooking', () => {
  beforeEach(() => {
    insertMock.mockReset().mockResolvedValue({ error: null })
    fromMock.mockClear()
  })

  it('starts at the service step, or date step if preselected', () => {
    const { result: fresh } = renderHook(() => useBooking())
    expect(fresh.current.state.step).toBe('service')

    const { result: preselected } = renderHook(() => useBooking('knotless'))
    expect(preselected.current.state.step).toBe('date')
    expect(preselected.current.state.serviceId).toBe('knotless')
  })

  it('advances through service -> date -> slot -> contact', () => {
    const { result } = renderHook(() => useBooking())
    act(() => result.current.selectService('cornrows'))
    expect(result.current.state.step).toBe('date')

    act(() => result.current.selectDate('2026-08-11'))
    expect(result.current.state.step).toBe('slot')

    act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
    expect(result.current.state.step).toBe('contact')
  })

  it('rejects submit with invalid contact info and reports errors, without calling Supabase', async () => {
    const { result } = renderHook(() => useBooking('cornrows'))
    act(() => result.current.selectDate('2026-08-11'))
    act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))

    let success = true
    await act(async () => {
      success = await result.current.submit()
    })
    expect(success).toBe(false)
    expect(result.current.state.step).toBe('contact')
    expect(result.current.errors.name).toBeTruthy()
    expect(result.current.errors.email).toBeTruthy()
    expect(result.current.errors.phone).toBeTruthy()
    expect(insertMock).not.toHaveBeenCalled()
  })

  it('inserts a booking and confirms on success', async () => {
    const { result } = renderHook(() => useBooking('cornrows'))
    act(() => result.current.selectDate('2026-08-11'))
    act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
    act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))

    let success = false
    await act(async () => {
      success = await result.current.submit()
    })

    expect(fromMock).toHaveBeenCalledWith('bookings')
    expect(insertMock).toHaveBeenCalledWith({
      service_id: 'cornrows',
      date: '2026-08-11',
      time: '9:00 AM',
      contact_name: 'Jane Doe',
      contact_phone: '5551234567',
      contact_email: 'jane@example.com',
      contact_notes: '',
    })
    expect(success).toBe(true)
    expect(result.current.state.step).toBe('confirmed')
  })

  it('returns to the slot step with a French error message on a slot conflict (23505)', async () => {
    insertMock.mockResolvedValue({ error: { code: '23505', message: 'duplicate key value violates unique constraint' } })
    const { result } = renderHook(() => useBooking('cornrows'))
    act(() => result.current.selectDate('2026-08-11'))
    act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
    act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))

    let success = true
    await act(async () => {
      success = await result.current.submit()
    })

    expect(success).toBe(false)
    expect(result.current.state.step).toBe('slot')
    expect(result.current.state.slot).toBeNull()
    expect(result.current.submitError).toMatch(/réservé/i)
  })

  it('surfaces a generic error message for a non-conflict submit failure', async () => {
    insertMock.mockResolvedValue({ error: { code: '500', message: 'network error' } })
    const { result } = renderHook(() => useBooking('cornrows'))
    act(() => result.current.selectDate('2026-08-11'))
    act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true }))
    act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))

    await act(async () => {
      await result.current.submit()
    })

    expect(result.current.submitError).toBe('network error')
    expect(result.current.state.step).toBe('contact')
  })

  it('resets to the initial state', () => {
    const { result } = renderHook(() => useBooking())
    act(() => result.current.selectService('locs'))
    act(() => result.current.reset())
    expect(result.current.state.step).toBe('service')
    expect(result.current.state.serviceId).toBeNull()
  })
})
  • Step 2: Run the tests to verify they fail

Run: npx vitest run src/hooks/useBooking.test.ts Expected: FAIL — submit() is still synchronous and doesn't call Supabase; submitError doesn't exist yet.

  • Step 3: Implement

Replace src/hooks/useBooking.ts entirely with:

import { useState, useCallback } from 'react'
import type { TimeSlot } from '../data/availability'
import { isValidEmail } from '../lib/validation'
import { supabase } from '../lib/supabase'

export interface BookingContact {
  name: string
  phone: string
  email: string
  notes: string
}

export type BookingStep = 'service' | 'date' | 'slot' | 'contact' | 'confirmed'

export interface BookingState {
  step: BookingStep
  serviceId: string | null
  date: string | null
  slot: TimeSlot | null
  contact: BookingContact
}

type BookingErrors = Partial<Record<keyof BookingContact, string>>

const SLOT_CONFLICT_CODE = '23505'

function initialState(initialServiceId?: string): BookingState {
  return {
    step: initialServiceId ? 'date' : 'service',
    serviceId: initialServiceId ?? null,
    date: null,
    slot: null,
    contact: { name: '', phone: '', email: '', notes: '' },
  }
}

export default function useBooking(initialServiceId?: string) {
  const [state, setState] = useState<BookingState>(() => initialState(initialServiceId))
  const [errors, setErrors] = useState<BookingErrors>({})
  const [submitError, setSubmitError] = useState('')

  const selectService = useCallback((serviceId: string) => {
    setState((s) => ({ ...s, serviceId, step: 'date' }))
  }, [])

  const selectDate = useCallback((date: string) => {
    setState((s) => ({ ...s, date, step: 'slot' }))
  }, [])

  const selectSlot = useCallback((slot: TimeSlot) => {
    setState((s) => ({ ...s, slot, step: 'contact' }))
  }, [])

  const updateContact = useCallback((patch: Partial<BookingContact>) => {
    setState((s) => ({ ...s, contact: { ...s.contact, ...patch } }))
  }, [])

  const submit = useCallback(async (): Promise<boolean> => {
    const nextErrors: BookingErrors = {}
    if (!state.contact.name.trim()) nextErrors.name = 'Le nom est requis.'
    if (!isValidEmail(state.contact.email)) nextErrors.email = 'Une adresse courriel valide est requise.'
    if (!state.contact.phone.trim()) nextErrors.phone = 'Le téléphone est requis.'
    setErrors(nextErrors)
    if (Object.keys(nextErrors).length > 0) {
      return false
    }
    if (!state.serviceId || !state.date || !state.slot) {
      return false
    }

    setSubmitError('')
    const { error } = await supabase.from('bookings').insert({
      service_id: state.serviceId,
      date: state.date,
      time: state.slot.time,
      contact_name: state.contact.name,
      contact_phone: state.contact.phone,
      contact_email: state.contact.email,
      contact_notes: state.contact.notes,
    })

    if (error) {
      if (error.code === SLOT_CONFLICT_CODE) {
        setSubmitError("Ce créneau vient d'être réservé par quelqu'un d'autre. Veuillez en choisir un autre.")
        setState((s) => ({ ...s, step: 'slot', slot: null }))
      } else {
        setSubmitError(error.message)
      }
      return false
    }

    setState((s) => ({ ...s, step: 'confirmed' }))
    return true
  }, [state])

  const reset = useCallback(() => {
    setState(initialState(initialServiceId))
    setErrors({})
    setSubmitError('')
  }, [initialServiceId])

  return { state, errors, submitError, selectService, selectDate, selectSlot, updateContact, submit, reset }
}
  • Step 4: Run the tests to verify they pass

Run: npx vitest run src/hooks/useBooking.test.ts Expected: PASS, 7 tests.

  • Step 5: Run full local verification

Run: npx vitest run && npm run build && npm run lint Expected: all pass — this was the last piece Task 9's Book.tsx was waiting on (booking.submitError now exists). InquiryModal.tsx still doesn't persist (fixed in Task 11), but nothing else in the suite depends on that.

  • Step 6: Commit
git add src/hooks/useBooking.ts src/hooks/useBooking.test.ts
git commit -m "feat: persist real bookings to Supabase with slot-conflict handling"

Task 11: Persist real inquiries

Files:

  • Modify: src/components/InquiryModal.tsx
  • Modify: src/components/InquiryModal.test.tsx

Interfaces:

  • Consumes: supabase from src/lib/supabase.ts

  • Produces: InquiryModal's submit handler now persists to inquiries. Props (product, onClose) unchanged.

  • Step 1: Write the failing tests

Replace src/components/InquiryModal.test.tsx entirely with:

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'

const insertMock = vi.fn()
const fromMock = vi.fn(() => ({ insert: insertMock }))

vi.mock('../lib/supabase', () => ({
  supabase: { from: (table: string) => fromMock(table) },
}))

import InquiryModal from './InquiryModal'
import type { Product } from '../data/products'

const product: Product = {
  id: 'edge-control', name: 'Silk Edge Control', category: 'hair-care',
  price: 12, image: '/x.jpg', description: 'Hold with shine.', inStock: true,
}

describe('InquiryModal', () => {
  beforeEach(() => {
    insertMock.mockReset().mockResolvedValue({ error: null })
    fromMock.mockClear()
  })

  it('shows a validation error when submitting without a name or valid email, without calling Supabase', async () => {
    render(<InquiryModal product={product} onClose={vi.fn()} />)
    await userEvent.click(screen.getByRole('button', { name: /envoyer la demande/i }))
    expect(screen.getByText(/nom est requis/i)).toBeInTheDocument()
    expect(screen.getByText(/adresse courriel valide est requise/i)).toBeInTheDocument()
    expect(insertMock).not.toHaveBeenCalled()
  })

  it('inserts the inquiry and shows a confirmation after a valid submit', async () => {
    render(<InquiryModal product={product} onClose={vi.fn()} />)
    await userEvent.type(screen.getByLabelText(/nom/i), 'Jane Doe')
    await userEvent.type(screen.getByLabelText(/courriel/i), 'jane@example.com')
    await userEvent.click(screen.getByRole('button', { name: /envoyer la demande/i }))

    await waitFor(() =>
      expect(insertMock).toHaveBeenCalledWith({
        product_id: 'edge-control',
        contact_name: 'Jane Doe',
        contact_email: 'jane@example.com',
        contact_notes: '',
      }),
    )
    expect(await screen.findByText(/silk edge control/i)).toBeInTheDocument()
    expect(screen.getByText(/mira vous recontactera/i)).toBeInTheDocument()
  })

  it('shows an inline error and keeps the form open when the insert fails', async () => {
    insertMock.mockResolvedValue({ error: { message: 'network error' } })
    render(<InquiryModal product={product} onClose={vi.fn()} />)
    await userEvent.type(screen.getByLabelText(/nom/i), 'Jane Doe')
    await userEvent.type(screen.getByLabelText(/courriel/i), 'jane@example.com')
    await userEvent.click(screen.getByRole('button', { name: /envoyer la demande/i }))

    expect(await screen.findByText(/network error/i)).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /envoyer la demande/i })).toBeInTheDocument()
  })

  it('calls onClose when Close is clicked', async () => {
    const onClose = vi.fn()
    render(<InquiryModal product={product} onClose={onClose} />)
    await userEvent.click(screen.getByRole('button', { name: /fermer/i }))
    expect(onClose).toHaveBeenCalled()
  })

  it('calls onClose when Escape is pressed', async () => {
    const onClose = vi.fn()
    render(<InquiryModal product={product} onClose={onClose} />)
    await userEvent.keyboard('{Escape}')
    expect(onClose).toHaveBeenCalled()
  })

  it('exposes an accessible dialog labelled by its heading', () => {
    render(<InquiryModal product={product} onClose={vi.fn()} />)
    const dialog = screen.getByRole('dialog', { name: /une question sur silk edge control/i })
    expect(dialog).toHaveAttribute('aria-modal', 'true')
  })
})
  • Step 2: Run the tests to verify they fail

Run: npx vitest run src/components/InquiryModal.test.tsx Expected: FAIL — handleSubmit doesn't call Supabase yet, and there's no submit-error state.

  • Step 3: Implement

Modify src/components/InquiryModal.tsx. Read the current file first (all French copy already in place from the earlier translation pass — preserve every string exactly, only change the submit logic). The full modified file:

import { useEffect, useRef, useState, type FormEvent } from 'react'
import type { Product } from '../data/products'
import { isValidEmail } from '../lib/validation'
import { supabase } from '../lib/supabase'
import styles from './InquiryModal.module.css'

interface InquiryModalProps {
  product: Product
  onClose: () => void
}

interface Errors {
  name?: string
  email?: string
}

const HEADING_ID = 'inquiry-modal-heading'

function InquiryModal({ product, onClose }: InquiryModalProps) {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [notes, setNotes] = useState('')
  const [errors, setErrors] = useState<Errors>({})
  const [submitError, setSubmitError] = useState('')
  const [submitted, setSubmitted] = useState(false)
  const nameInputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    nameInputRef.current?.focus()
  }, [])

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') onClose()
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [onClose])

  async function handleSubmit(e: FormEvent) {
    e.preventDefault()
    const nextErrors: Errors = {}
    if (!name.trim()) nextErrors.name = 'Le nom est requis.'
    if (!isValidEmail(email)) nextErrors.email = 'Une adresse courriel valide est requise.'
    setErrors(nextErrors)
    if (Object.keys(nextErrors).length > 0) {
      return
    }

    setSubmitError('')
    const { error } = await supabase.from('inquiries').insert({
      product_id: product.id,
      contact_name: name,
      contact_email: email,
      contact_notes: notes,
    })

    if (error) {
      setSubmitError(error.message)
      return
    }

    setSubmitted(true)
  }

  return (
    <div
      className={styles.overlay}
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose()
      }}
    >
      <div
        className={styles.modal}
        role="dialog"
        aria-modal="true"
        aria-labelledby={HEADING_ID}
      >
        {submitted ? (
          <>
            <p>Merci — Mira vous recontactera bientôt au sujet de {product.name}.</p>
            <button type="button" onClick={onClose}>Fermer</button>
          </>
        ) : (
          <form onSubmit={handleSubmit}>
            <h3 id={HEADING_ID}>Une question sur {product.name}</h3>
            <div className={styles.field}>
              <label htmlFor="inquiry-name">Nom</label>
              <input
                id="inquiry-name"
                ref={nameInputRef}
                value={name}
                onChange={(e) => setName(e.target.value)}
              />
              {errors.name && <span className={styles.error}>{errors.name}</span>}
            </div>
            <div className={styles.field}>
              <label htmlFor="inquiry-email">Courriel</label>
              <input id="inquiry-email" value={email} onChange={(e) => setEmail(e.target.value)} />
              {errors.email && <span className={styles.error}>{errors.email}</span>}
            </div>
            <div className={styles.field}>
              <label htmlFor="inquiry-notes">Notes</label>
              <textarea id="inquiry-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
            </div>
            {submitError && <p className={styles.error}>{submitError}</p>}
            <button type="submit">Envoyer la demande</button>
            <button type="button" onClick={onClose}>Fermer</button>
          </form>
        )}
      </div>
    </div>
  )
}

export default InquiryModal
  • Step 4: Run the tests to verify they pass

Run: npx vitest run src/components/InquiryModal.test.tsx Expected: PASS, 6 tests.

  • Step 5: Run full local verification

Run: npx vitest run && npm run build && npm run lint Expected: all pass. This is the last frontend task — the full suite should be clean end to end.

  • Step 6: Commit
git add src/components/InquiryModal.tsx src/components/InquiryModal.test.tsx
git commit -m "feat: persist real inquiries to Supabase"

Task 12: Deploy and live verification

Files:

  • None (this task runs commands against the live Supabase project and Vercel deployment; no code changes expected unless verification uncovers a real bug, in which case fix it in the relevant file from Tasks 1-11 and note the deviation)

Interfaces:

  • Consumes: everything from Tasks 1-11, plus the already-configured live Supabase project and Vercel deployment (env vars already set, vercel.json SPA rewrite already in place — both done outside this plan, in the same session preceding it)

  • Step 1: Re-run the seed script against the live project

Task 6 already verified the image-upload mechanism works; this step re-runs it now that all the product/service data and frontend code are final, ensuring the live database and Storage bucket both reflect the current state:

SUPABASE_URL=https://ylxppnclfpnnolnjkbwv.supabase.co SUPABASE_SERVICE_ROLE_KEY=<live project service role key> npm run seed

Expected: Seeded 10 products, 4 services, and the default schedule. and Uploaded 14 images to the salon-images bucket.

  • Step 2: Full local verification suite
npx vitest run
npm run build
npm run lint

Expected: all green — this confirms Tasks 1-11 integrate correctly as a whole, not just individually.

  • Step 3: Redeploy to Vercel
npx vercel --prod

Expected: deployment succeeds, aliased to the production URL.

  • Step 4: Live smoke test — Shop and Home show real data

Using a real browser (or Playwright automation if available), visit the deployed production URL:

  • / — confirm the "Styles Signature" and "Sélection boutique" sections show the real seeded products/services with real images (not broken image icons — this specifically verifies Task 6's Storage upload worked and Task 1's resolveImageUrl correctly resolves it)

  • /shop — confirm all 10 products render with real images, category filters work

  • Step 5: Live smoke test — a real booking persists

On the deployed site, go through the full /book flow: pick a service, pick a date a few days out, pick an available time slot, fill in contact info with a clearly-marked test identity (e.g. name "Test Client", a real-format but obviously-fake email), submit. Confirm the confirmation message appears.

Then verify directly against the database that it actually persisted (using the service role key, bypassing RLS):

curl -s "https://ylxppnclfpnnolnjkbwv.supabase.co/rest/v1/bookings?contact_name=eq.Test%20Client" \
  -H "apikey: <service role key>" -H "Authorization: Bearer <service role key>"

Expected: one row matching what was submitted, status: "confirmed".

Also confirm the slot now shows as unavailable if you revisit /book and pick the same date — the booked_slots mirror should reflect it (this exercises the full chain: insert → sync_booked_slot() trigger from Phase 2A → useAvailability's getSlotsForDate correctly excluding it).

  • Step 6: Live smoke test — a real inquiry persists

On /shop, click "Poser une question" on any product, fill in the form with the same test identity, submit. Confirm the "Merci" confirmation appears.

Verify directly:

curl -s "https://ylxppnclfpnnolnjkbwv.supabase.co/rest/v1/inquiries?contact_name=eq.Test%20Client" \
  -H "apikey: <service role key>" -H "Authorization: Bearer <service role key>"

Expected: one row matching what was submitted, status: "new".

  • Step 7: Clean up the smoke-test rows
curl -s -X DELETE "https://ylxppnclfpnnolnjkbwv.supabase.co/rest/v1/bookings?contact_name=eq.Test%20Client" \
  -H "apikey: <service role key>" -H "Authorization: Bearer <service role key>"
curl -s -X DELETE "https://ylxppnclfpnnolnjkbwv.supabase.co/rest/v1/inquiries?contact_name=eq.Test%20Client" \
  -H "apikey: <service role key>" -H "Authorization: Bearer <service role key>"
curl -s -X DELETE "https://ylxppnclfpnnolnjkbwv.supabase.co/rest/v1/booked_slots?date=eq.<the test date used>" \
  -H "apikey: <service role key>" -H "Authorization: Bearer <service role key>"
  • Step 8: Live smoke test — admin panel still works alongside the now-live public site

Visit /admin/login on the deployed production URL, sign in with the real admin credentials, confirm the products/services/bookings/inquiries lists show the same real data the public site now reads and writes (they always shared the same tables via RLS — this step confirms nothing about the public-site changes broke the admin panel's own queries, which is plausible only if a shared type or shared file was touched in a way that leaked between them, which Tasks 1-11 were designed to avoid).

  • Step 9: Report and commit any deviations

If any live-verification step in this task uncovered a real bug (not just a local/live environment difference), fix it in the relevant file, re-run the full local verification suite, and commit with a clear message describing what was wrong and why. If everything passed as designed, there's nothing to commit for this task — it's verification-only.


Self-Review Notes

  • Spec coverage: live products/services data (Tasks 3-4, 7-8), real availability computation (Task 2), real availability data fetching (Task 5), image resolution + upload (Tasks 1, 6), real booking persistence (Tasks 9-10), real inquiry persistence (Task 11), deploy + live verification (Task 12). All spec sections covered.
  • Type consistency: Product/Service/TimeSlot interfaces are defined once (Tasks 2-4) and consumed unchanged by every later task; ScheduleTemplate/ScheduleException/BookedSlot are defined once in Task 2 and consumed by Task 5's hook. useBooking's submitError field, introduced in Task 10, is referenced by Task 9's Book.tsx ahead of Task 10 landing — this ordering dependency is called out explicitly in Task 9's implementation note rather than left as a silent gap.
  • No placeholders: every step has literal file contents and exact commands, except the intentional live-credential substitutions (<live project service role key>) in Task 6 and Task 12, which are necessarily environment-specific and clearly marked, not vague TODOs.
  • Cross-task test breakage is intentional and called out: Tasks 2-4 each explicitly note that the full suite will show new failures in files not yet updated in the same task, and instruct the implementer to confirm the failures are exactly the expected ones before moving on — this mirrors the same pattern used successfully in the Phase 2A and Phase 2B plans for this project, where a plan's own literal code was occasionally found to have a real bug (an ESLint rule collision, a TypeScript compile error in a mock signature) that implementers caught by verifying rather than assuming. The same discipline is asked for here.