diff --git a/src/hooks/useAvailability.ts b/src/hooks/useAvailability.ts index 30cd05a..9dc1678 100644 --- a/src/hooks/useAvailability.ts +++ b/src/hooks/useAvailability.ts @@ -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], + ) } diff --git a/src/pages/Book.test.tsx b/src/pages/Book.test.tsx index 1486d66..a12c035 100644 --- a/src/pages/Book.test.tsx +++ b/src/pages/Book.test.tsx @@ -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() }) }) diff --git a/src/pages/Book.tsx b/src/pages/Book.tsx index 5789ada..217a82b 100644 --- a/src/pages/Book.tsx +++ b/src/pages/Book.tsx @@ -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([]) + const [servicesError, setServicesError] = useState('') + const [slots, setSlots] = useState([]) + const [nextAvailable, setNextAvailable] = useState(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 (

Réserver un rendez-vous

+ {servicesError &&

{servicesError}

} + {availability.error &&

{availability.error}

} + {booking.submitError &&

{booking.submitError}

}
{ e.preventDefault() - booking.submit() + void booking.submit() }} >