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:
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user