feat: persist real bookings to Supabase with slot-conflict handling

Make useBooking's submit() function async and integrate with Supabase bookings table.
Add submitError field for displaying conflict/error messages to users.
Handle SQLSTATE 23505 (duplicate key) by returning to slot selection.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-12 22:43:08 +02:00
parent 930a080ccf
commit 4ba6f3e178
2 changed files with 104 additions and 8 deletions

View File

@@ -1,8 +1,25 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const insertMock = vi.fn()
const fromMock = vi.fn((table: string) => {
// table parameter is used to track the call in test assertions via fromMock.toHaveBeenCalledWith('bookings')
void table // Explicitly reference to satisfy linter
return { 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')
@@ -24,32 +41,81 @@ describe('useBooking', () => {
expect(result.current.state.step).toBe('contact')
})
it('rejects submit with invalid contact info and reports errors', () => {
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
act(() => { success = result.current.submit() })
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('confirms booking with valid contact info', () => {
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
act(() => { success = result.current.submit() })
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'))

View File

@@ -1,6 +1,7 @@
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
@@ -21,6 +22,8 @@ export interface BookingState {
type BookingErrors = Partial<Record<keyof BookingContact, string>>
const SLOT_CONFLICT_CODE = '23505'
function initialState(initialServiceId?: string): BookingState {
return {
step: initialServiceId ? 'date' : 'service',
@@ -34,6 +37,7 @@ function initialState(initialServiceId?: string): BookingState {
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' }))
@@ -51,7 +55,7 @@ export default function useBooking(initialServiceId?: string) {
setState((s) => ({ ...s, contact: { ...s.contact, ...patch } }))
}, [])
const submit = useCallback((): boolean => {
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.'
@@ -60,14 +64,40 @@ export default function useBooking(initialServiceId?: string) {
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.contact])
}, [state])
const reset = useCallback(() => {
setState(initialState(initialServiceId))
setErrors({})
setSubmitError('')
}, [initialServiceId])
return { state, errors, selectService, selectDate, selectSlot, updateContact, submit, reset }
return { state, errors, submitError, selectService, selectDate, selectSlot, updateContact, submit, reset }
}