feat: wire Book page to live services/availability data

Replaces static services import and mock slot/date functions with
fetchServices() (Task 4) and useAvailability() (Task 5).

Fixes two real bugs surfaced by this task's cross-task integration:

1. Book.test.tsx's useAvailability mock returned fresh inline-wrapped
   functions every render, causing Book.tsx's exhaustive-deps-correct
   effect to re-fire every render (infinite loop, hung the test
   indefinitely). Fixed by returning the already-stable vi.fn()
   references directly.

2. useAvailability.ts's fire-and-forget `void load()` had no
   try/catch, so a synchronous throw from a mocked/unexpected
   supabase.from() call became an unhandled promise rejection
   (surfaced via Home.test.tsx rendering Book with an unmocked
   schedule_template/schedule_exceptions query). Added try/catch;
   pure robustness fix, no production behavior change since the real
   client never throws synchronously.

Also fixed a react-hooks/set-state-in-effect lint error (moved the
slot effect's guard clause inside its async closure, same pattern
used throughout Phase 2B) and the resulting exhaustive-deps warning,
by memoizing useAvailability's return value so Book.tsx can safely
depend on the whole `availability` object without risking the same
re-render-loop class of bug this task started with.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-12 22:35:12 +02:00
parent f45a5bf8ec
commit 930a080ccf
3 changed files with 126 additions and 41 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { supabase } from '../lib/supabase'
import {
computeAvailableSlots,
@@ -24,23 +24,28 @@ export default function useAvailability(): UseAvailabilityResult {
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)
try {
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)
return
}
if (exceptionsResult.error) {
setError(exceptionsResult.error.message)
} catch (err) {
setError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
setLoading(false)
return
}
setTemplate(templateResult.data as ScheduleTemplate)
setExceptions((exceptionsResult.data ?? []) as ScheduleException[])
setLoading(false)
}
void load()
}, [])
@@ -87,5 +92,8 @@ export default function useAvailability(): UseAvailabilityResult {
[template, exceptions],
)
return { loading, error, getSlotsForDate, findNextAvailableDate }
return useMemo(
() => ({ loading, error, getSlotsForDate, findNextAvailableDate }),
[loading, error, getSlotsForDate, findNextAvailableDate],
)
}

View File

@@ -1,7 +1,42 @@
import { render, screen } from '@testing-library/react'
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 } from 'vitest'
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()
// A single stable object, matching the real hook's useMemo-stabilized return
// value — a fresh object literal on every mocked call would make Book.tsx's
// effect (which depends on the whole `availability` object) re-fire every
// render, since the effect can't tell a "new but equivalent" object apart
// from a genuinely changed one.
const availabilityMockValue = {
loading: false,
error: '',
getSlotsForDate: getSlotsForDateMock,
findNextAvailableDate: findNextAvailableDateMock,
}
vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) },
}))
vi.mock('../lib/resolveImageUrl', () => ({
resolveImageUrl: (path: string) => path,
}))
vi.mock('../hooks/useAvailability', () => ({
default: () => availabilityMockValue,
}))
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 }[]) {
@@ -15,9 +50,19 @@ function renderBook(initialEntries: { pathname: string; state?: unknown }[]) {
}
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(screen.getByText(/tresses sans nœuds/i)).toBeInTheDocument()
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()
})
@@ -27,25 +72,18 @@ describe('Book page', () => {
expect(screen.getByLabelText(/choisissez une date/i)).toBeInTheDocument()
})
it('walks through date -> slot -> contact -> confirmation', async () => {
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')
const slotButton = (await screen.findAllByRole('button', { name: /am|pm/i }))[0]
await userEvent.click(slotButton)
await userEvent.type(await screen.findByLabelText(/^nom$/i), 'Jane Doe')
await userEvent.type(screen.getByLabelText(/^courriel$/i), 'jane@example.com')
await userEvent.type(screen.getByLabelText(/^téléphone$/i), '5551234567')
await userEvent.click(screen.getByRole('button', { name: /confirmer/i }))
expect(await screen.findByText(/mira vous attend/i)).toBeInTheDocument()
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' } }])
// Sundays are fully closed in the mock availability generator
await userEvent.type(screen.getByLabelText(/choisissez une date/i), '2026-08-09')
expect(await screen.findByText(/aucun créneau disponible/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /essayez .* à la place/i })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: /essayez .* à la place/i })).toBeInTheDocument()
})
})

View File

@@ -1,33 +1,71 @@
import { useLocation } from 'react-router-dom'
import { useMemo, useState } from 'react'
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 { services } from '../data/services'
import { getSlotsForDate, getNextAvailableDate } from '../data/availability'
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()
const slots = useMemo(() => (dateInput ? getSlotsForDate(dateInput) : []), [dateInput])
const availableSlots = slots.filter((s) => s.available)
const nextAvailable = useMemo(
() => (dateInput && availableSlots.length === 0 ? getNextAvailableDate(dateInput) : null),
[dateInput, availableSlots.length],
)
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(() => {
let cancelled = false
async function load() {
if (!dateInput || availability.loading) {
setSlots([])
setNextAvailable(null)
return
}
setNextAvailable(null)
const result = await availability.getSlotsForDate(dateInput)
if (cancelled) return
setSlots(result)
if (!result.some((s) => s.available)) {
const next = await availability.findNextAvailableDate(dateInput)
if (!cancelled) setNextAvailable(next)
}
}
void load()
return () => {
cancelled = true
}
}, [dateInput, booking.state.step, availability])
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
@@ -54,6 +92,7 @@ function Book() {
{(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
@@ -101,7 +140,7 @@ function Book() {
<form
onSubmit={(e) => {
e.preventDefault()
booking.submit()
void booking.submit()
}}
>
<div className={styles.field}>