feat: collect hair intake details and reference photos when booking

Adds hair length/type, scalp sensitivities, wet/dry state, desired
style, who provides extensions, guest count, and two optional
reference photos to the booking form. Photos upload to a new private
booking-photos storage bucket; admin can view intake details and
signed photo URLs per booking.

Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH
This commit is contained in:
belviskhoremk
2026-08-25 10:42:58 +02:00
parent efce78ea93
commit cf2fc1aa42
9 changed files with 422 additions and 68 deletions

View File

@@ -3,17 +3,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const insertMock = vi.fn() const insertMock = vi.fn()
const fromMock = vi.fn<(table: string) => { insert: typeof insertMock }>(() => ({ insert: insertMock })) const fromMock = vi.fn<(table: string) => { insert: typeof insertMock }>(() => ({ insert: insertMock }))
const uploadBookingPhotoMock = vi.fn()
vi.mock('../lib/supabase', () => ({ vi.mock('../lib/supabase', () => ({
supabase: { from: (table: string) => fromMock(table) }, supabase: { from: (table: string) => fromMock(table) },
})) }))
vi.mock('../lib/storage', () => ({
uploadBookingPhoto: (file: File, path: string) => uploadBookingPhotoMock(file, path),
}))
import useBooking from './useBooking' import useBooking from './useBooking'
describe('useBooking', () => { describe('useBooking', () => {
beforeEach(() => { beforeEach(() => {
insertMock.mockReset().mockResolvedValue({ error: null }) insertMock.mockReset().mockResolvedValue({ error: null })
fromMock.mockClear() fromMock.mockClear()
uploadBookingPhotoMock.mockReset().mockImplementation((_file: File, path: string) => Promise.resolve(path))
}) })
it('starts at the service step, or date step if preselected', () => { it('starts at the service step, or date step if preselected', () => {
@@ -54,11 +60,62 @@ describe('useBooking', () => {
expect(insertMock).not.toHaveBeenCalled() expect(insertMock).not.toHaveBeenCalled()
}) })
it('rejects submit with missing intake answers 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 }))
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.errors.hairLength).toBeTruthy()
expect(result.current.errors.hairType).toBeTruthy()
expect(result.current.errors.hairState).toBeTruthy()
expect(result.current.errors.extensionsProvidedBy).toBeTruthy()
expect(insertMock).not.toHaveBeenCalled()
})
it('uploads provided reference photos and stores their paths', 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' }))
const currentHairPhoto = new File(['a'], 'current.jpg', { type: 'image/jpeg' })
const inspirationPhoto = new File(['b'], 'inspiration.jpg', { type: 'image/jpeg' })
act(() =>
result.current.updateIntake({
hairLength: 'court',
hairType: 'extensions',
hairState: 'mouilles',
extensionsProvidedBy: 'cliente',
currentHairPhoto,
inspirationPhoto,
}),
)
await act(async () => {
await result.current.submit()
})
expect(uploadBookingPhotoMock).toHaveBeenCalledWith(currentHairPhoto, expect.stringMatching(/-current\.jpg$/))
expect(uploadBookingPhotoMock).toHaveBeenCalledWith(inspirationPhoto, expect.stringMatching(/-inspiration\.jpg$/))
expect(insertMock).toHaveBeenCalledWith(
expect.objectContaining({
current_hair_photo_path: expect.stringMatching(/-current\.jpg$/),
inspiration_photo_path: expect.stringMatching(/-inspiration\.jpg$/),
}),
)
})
it('inserts a booking and confirms on success', async () => { it('inserts a booking and confirms on success', async () => {
const { result } = renderHook(() => useBooking('cornrows')) const { result } = renderHook(() => useBooking('cornrows'))
act(() => result.current.selectDate('2026-08-11')) act(() => result.current.selectDate('2026-08-11'))
act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) 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' })) act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))
act(() => result.current.updateIntake({ hairLength: 'long', hairType: 'naturel', hairState: 'secs', extensionsProvidedBy: 'salon' }))
let success = false let success = false
await act(async () => { await act(async () => {
@@ -74,6 +131,15 @@ describe('useBooking', () => {
contact_phone: '5551234567', contact_phone: '5551234567',
contact_email: 'jane@example.com', contact_email: 'jane@example.com',
contact_notes: '', contact_notes: '',
hair_length: 'long',
hair_type: 'naturel',
scalp_sensitivities: '',
hair_state: 'secs',
desired_style: '',
extensions_provided_by: 'salon',
guest_count: 0,
current_hair_photo_path: null,
inspiration_photo_path: null,
}) })
expect(success).toBe(true) expect(success).toBe(true)
expect(result.current.state.step).toBe('confirmed') expect(result.current.state.step).toBe('confirmed')
@@ -85,6 +151,7 @@ describe('useBooking', () => {
act(() => result.current.selectDate('2026-08-11')) act(() => result.current.selectDate('2026-08-11'))
act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) 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' })) act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))
act(() => result.current.updateIntake({ hairLength: 'long', hairType: 'naturel', hairState: 'secs', extensionsProvidedBy: 'salon' }))
let success = true let success = true
await act(async () => { await act(async () => {
@@ -103,6 +170,7 @@ describe('useBooking', () => {
act(() => result.current.selectDate('2026-08-11')) act(() => result.current.selectDate('2026-08-11'))
act(() => result.current.selectSlot({ date: '2026-08-11', time: '9:00 AM', available: true })) 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' })) act(() => result.current.updateContact({ name: 'Jane Doe', email: 'jane@example.com', phone: '5551234567' }))
act(() => result.current.updateIntake({ hairLength: 'long', hairType: 'naturel', hairState: 'secs', extensionsProvidedBy: 'salon' }))
await act(async () => { await act(async () => {
await result.current.submit() await result.current.submit()

View File

@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'
import type { TimeSlot } from '../data/availability' import type { TimeSlot } from '../data/availability'
import { isValidEmail } from '../lib/validation' import { isValidEmail } from '../lib/validation'
import { supabase } from '../lib/supabase' import { supabase } from '../lib/supabase'
import { uploadBookingPhoto } from '../lib/storage'
export interface BookingContact { export interface BookingContact {
name: string name: string
@@ -10,6 +11,23 @@ export interface BookingContact {
notes: string notes: string
} }
export type HairLength = '' | 'court' | 'moyen' | 'long'
export type HairType = '' | 'naturel' | 'defrise' | 'extensions' | 'teints'
export type HairState = '' | 'mouilles' | 'secs'
export type ExtensionsProvidedBy = '' | 'cliente' | 'salon'
export interface BookingIntake {
hairLength: HairLength
hairType: HairType
scalpSensitivities: string
hairState: HairState
desiredStyle: string
extensionsProvidedBy: ExtensionsProvidedBy
guestCount: number
currentHairPhoto: File | null
inspirationPhoto: File | null
}
export type BookingStep = 'service' | 'date' | 'slot' | 'contact' | 'confirmed' export type BookingStep = 'service' | 'date' | 'slot' | 'contact' | 'confirmed'
export interface BookingState { export interface BookingState {
@@ -18,12 +36,27 @@ export interface BookingState {
date: string | null date: string | null
slot: TimeSlot | null slot: TimeSlot | null
contact: BookingContact contact: BookingContact
intake: BookingIntake
} }
type BookingErrors = Partial<Record<keyof BookingContact, string>> type BookingErrors = Partial<Record<keyof BookingContact | 'hairLength' | 'hairType' | 'hairState' | 'extensionsProvidedBy', string>>
const SLOT_CONFLICT_CODE = '23505' const SLOT_CONFLICT_CODE = '23505'
function initialIntake(): BookingIntake {
return {
hairLength: '',
hairType: '',
scalpSensitivities: '',
hairState: '',
desiredStyle: '',
extensionsProvidedBy: '',
guestCount: 0,
currentHairPhoto: null,
inspirationPhoto: null,
}
}
function initialState(initialServiceId?: string): BookingState { function initialState(initialServiceId?: string): BookingState {
return { return {
step: initialServiceId ? 'date' : 'service', step: initialServiceId ? 'date' : 'service',
@@ -31,6 +64,7 @@ function initialState(initialServiceId?: string): BookingState {
date: null, date: null,
slot: null, slot: null,
contact: { name: '', phone: '', email: '', notes: '' }, contact: { name: '', phone: '', email: '', notes: '' },
intake: initialIntake(),
} }
} }
@@ -56,6 +90,10 @@ export default function useBooking(initialServiceId?: string) {
setState((s) => ({ ...s, contact: { ...s.contact, ...patch } })) setState((s) => ({ ...s, contact: { ...s.contact, ...patch } }))
}, []) }, [])
const updateIntake = useCallback((patch: Partial<BookingIntake>) => {
setState((s) => ({ ...s, intake: { ...s.intake, ...patch } }))
}, [])
const submit = useCallback(async (): Promise<boolean> => { const submit = useCallback(async (): Promise<boolean> => {
if (submitting) return false if (submitting) return false
@@ -63,6 +101,10 @@ export default function useBooking(initialServiceId?: string) {
if (!state.contact.name.trim()) nextErrors.name = 'Le nom est requis.' 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 (!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.' if (!state.contact.phone.trim()) nextErrors.phone = 'Le téléphone est requis.'
if (!state.intake.hairLength) nextErrors.hairLength = 'Veuillez indiquer la longueur de vos cheveux.'
if (!state.intake.hairType) nextErrors.hairType = 'Veuillez indiquer le type de vos cheveux.'
if (!state.intake.hairState) nextErrors.hairState = 'Veuillez indiquer si vos cheveux seront mouillés ou secs.'
if (!state.intake.extensionsProvidedBy) nextErrors.extensionsProvidedBy = 'Veuillez indiquer qui fournit les mèches/extensions.'
setErrors(nextErrors) setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) { if (Object.keys(nextErrors).length > 0) {
return false return false
@@ -73,29 +115,53 @@ export default function useBooking(initialServiceId?: string) {
setSubmitting(true) setSubmitting(true)
setSubmitError('') 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,
})
setSubmitting(false)
if (error) { try {
if (error.code === SLOT_CONFLICT_CODE) { const photoStamp = `${state.date}-${state.slot.time}-${Date.now()}`
setSubmitError("Ce créneau vient d'être réservé par quelqu'un d'autre. Veuillez en choisir un autre.") const currentHairPhotoPath = state.intake.currentHairPhoto
setState((s) => ({ ...s, step: 'slot', slot: null })) ? await uploadBookingPhoto(state.intake.currentHairPhoto, `${photoStamp}-current.jpg`)
} else { : null
setSubmitError(error.message) const inspirationPhotoPath = state.intake.inspirationPhoto
? await uploadBookingPhoto(state.intake.inspirationPhoto, `${photoStamp}-inspiration.jpg`)
: null
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,
hair_length: state.intake.hairLength,
hair_type: state.intake.hairType,
scalp_sensitivities: state.intake.scalpSensitivities,
hair_state: state.intake.hairState,
desired_style: state.intake.desiredStyle,
extensions_provided_by: state.intake.extensionsProvidedBy,
guest_count: state.intake.guestCount,
current_hair_photo_path: currentHairPhotoPath,
inspiration_photo_path: inspirationPhotoPath,
})
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
} }
return false
}
setState((s) => ({ ...s, step: 'confirmed' })) setState((s) => ({ ...s, step: 'confirmed' }))
return true return true
} catch (err) {
setSubmitError(err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : String(err))
return false
} finally {
setSubmitting(false)
}
}, [state, submitting]) }, [state, submitting])
const reset = useCallback(() => { const reset = useCallback(() => {
@@ -105,5 +171,5 @@ export default function useBooking(initialServiceId?: string) {
setSubmitting(false) setSubmitting(false)
}, [initialServiceId]) }, [initialServiceId])
return { state, errors, submitError, submitting, selectService, selectDate, selectSlot, updateContact, submit, reset } return { state, errors, submitError, submitting, selectService, selectDate, selectSlot, updateContact, updateIntake, submit, reset }
} }

View File

@@ -10,3 +10,18 @@ export async function uploadSalonImage(file: File, path: string): Promise<string
if (error) throw error if (error) throw error
return path return path
} }
// booking-photos is a private bucket (client reference photos, not marketing
// images): anyone can upload, but only admin can read, so callers that need
// to display one must fetch a signed URL rather than a public one.
export async function uploadBookingPhoto(file: File, path: string): Promise<string> {
const { error } = await supabase.storage.from('booking-photos').upload(path, file, { upsert: true })
if (error) throw error
return path
}
export async function getBookingPhotoSignedUrl(path: string): Promise<string> {
const { data, error } = await supabase.storage.from('booking-photos').createSignedUrl(path, 60 * 10)
if (error) throw error
return data.signedUrl
}

View File

@@ -22,3 +22,17 @@
color: #a0453a; color: #a0453a;
font-size: 0.8rem; font-size: 0.8rem;
} }
.hint {
color: var(--color-charcoal);
font-size: 0.8rem;
opacity: 0.75;
}
.sectionTitle {
margin: var(--space-lg) 0 var(--space-sm);
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--color-gold);
}

View File

@@ -102,6 +102,10 @@ describe('Book page', () => {
await userEvent.type(await screen.findByLabelText(/^nom$/i), 'Jane Doe') 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(/^courriel$/i), 'jane@example.com')
await userEvent.type(screen.getByLabelText(/^téléphone$/i), '5551234567') await userEvent.type(screen.getByLabelText(/^téléphone$/i), '5551234567')
await userEvent.selectOptions(screen.getByLabelText(/longueur des cheveux/i), 'long')
await userEvent.selectOptions(screen.getByLabelText(/type de cheveux/i), 'naturel')
await userEvent.selectOptions(screen.getByLabelText(/mouillés\/lavés ou secs/i), 'secs')
await userEvent.selectOptions(screen.getByLabelText(/mèches\/extensions fournies par/i), 'salon')
await userEvent.click(screen.getByRole('button', { name: /confirmer/i })) await userEvent.click(screen.getByRole('button', { name: /confirmer/i }))
await waitFor(() => await waitFor(() =>
@@ -113,6 +117,15 @@ describe('Book page', () => {
contact_phone: '5551234567', contact_phone: '5551234567',
contact_email: 'jane@example.com', contact_email: 'jane@example.com',
contact_notes: '', contact_notes: '',
hair_length: 'long',
hair_type: 'naturel',
scalp_sensitivities: '',
hair_state: 'secs',
desired_style: '',
extensions_provided_by: 'salon',
guest_count: 0,
current_hair_photo_path: null,
inspiration_photo_path: null,
}), }),
) )
expect(await screen.findByText(/nattes collées/i)).toBeInTheDocument() expect(await screen.findByText(/nattes collées/i)).toBeInTheDocument()

View File

@@ -113,3 +113,24 @@
background: white; background: white;
color: var(--admin-primary); color: var(--admin-primary);
} }
.details {
display: flex;
flex-direction: column;
gap: var(--admin-space-xs);
padding: var(--admin-space-sm) 0;
}
.detailPhotos {
display: flex;
gap: var(--admin-space-sm);
margin-top: var(--admin-space-xs);
}
.detailPhoto {
width: 6rem;
height: 6rem;
object-fit: cover;
border-radius: calc(var(--admin-radius) / 2);
border: 1px solid var(--admin-border);
}

View File

@@ -7,11 +7,16 @@ const selectMock = vi.fn()
const eqUpdateMock = vi.fn() const eqUpdateMock = vi.fn()
const updateMock = vi.fn() const updateMock = vi.fn()
const fromMock = vi.fn() const fromMock = vi.fn()
const getBookingPhotoSignedUrlMock = vi.fn()
vi.mock('../../lib/supabase', () => ({ vi.mock('../../lib/supabase', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) }, supabase: { from: (...args: unknown[]) => fromMock(...args) },
})) }))
vi.mock('../../lib/storage', () => ({
getBookingPhotoSignedUrl: (path: string) => getBookingPhotoSignedUrlMock(path),
}))
const BOOKING = { const BOOKING = {
id: 'booking-1', id: 'booking-1',
date: '2026-09-15', date: '2026-09-15',
@@ -22,6 +27,15 @@ const BOOKING = {
contact_notes: '', contact_notes: '',
status: 'confirmed', status: 'confirmed',
services: { name: 'Knotless Braids' }, services: { name: 'Knotless Braids' },
hair_length: 'long',
hair_type: 'naturel',
scalp_sensitivities: 'Cuir chevelu sensible',
hair_state: 'secs',
desired_style: 'Box braids moyennes',
extensions_provided_by: 'salon',
guest_count: 1,
current_hair_photo_path: 'booking-1-current.jpg',
inspiration_photo_path: null,
} }
import AdminBookings from './AdminBookings' import AdminBookings from './AdminBookings'
@@ -33,6 +47,7 @@ describe('AdminBookings', () => {
eqUpdateMock.mockReset().mockResolvedValue({ error: null }) eqUpdateMock.mockReset().mockResolvedValue({ error: null })
updateMock.mockReset().mockReturnValue({ eq: eqUpdateMock }) updateMock.mockReset().mockReturnValue({ eq: eqUpdateMock })
fromMock.mockReset().mockReturnValue({ select: selectMock, update: updateMock }) fromMock.mockReset().mockReturnValue({ select: selectMock, update: updateMock })
getBookingPhotoSignedUrlMock.mockReset().mockResolvedValue('https://signed.example.com/photo.jpg')
vi.spyOn(window, 'confirm').mockReturnValue(true) vi.spyOn(window, 'confirm').mockReturnValue(true)
}) })
@@ -86,6 +101,22 @@ describe('AdminBookings', () => {
expect(updateMock).not.toHaveBeenCalled() expect(updateMock).not.toHaveBeenCalled()
}) })
it('shows intake details and fetches signed photo URLs when expanded', async () => {
render(<AdminBookings />)
await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /voir les détails/i }))
expect(screen.getByText('Box braids moyennes')).toBeInTheDocument()
expect(screen.getByText('Cuir chevelu sensible')).toBeInTheDocument()
await waitFor(() => expect(getBookingPhotoSignedUrlMock).toHaveBeenCalledWith('booking-1-current.jpg'))
expect(await screen.findByAltText('Cheveux actuels')).toHaveAttribute('src', 'https://signed.example.com/photo.jpg')
expect(screen.queryByAltText('Inspiration')).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: /masquer les détails/i }))
expect(screen.queryByText('Box braids moyennes')).not.toBeInTheDocument()
})
it('shows a badge on the booking date and filters the table when clicked', async () => { it('shows a badge on the booking date and filters the table when clicked', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true }) vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date('2026-09-01T12:00:00')) vi.setSystemTime(new Date('2026-09-01T12:00:00'))

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { Fragment, useEffect, useState } from 'react'
import { supabase } from '../../lib/supabase' import { supabase } from '../../lib/supabase'
import { getBookingPhotoSignedUrl } from '../../lib/storage'
import styles from './AdminBookings.module.css' import styles from './AdminBookings.module.css'
interface AdminBooking { interface AdminBooking {
@@ -12,6 +13,15 @@ interface AdminBooking {
contact_notes: string contact_notes: string
status: 'confirmed' | 'cancelled' | 'completed' status: 'confirmed' | 'cancelled' | 'completed'
services: { name: string } | null services: { name: string } | null
hair_length: string | null
hair_type: string | null
scalp_sensitivities: string
hair_state: string | null
desired_style: string
extensions_provided_by: string | null
guest_count: number
current_hair_photo_path: string | null
inspiration_photo_path: string | null
} }
const STATUS_LABELS: Record<AdminBooking['status'], string> = { const STATUS_LABELS: Record<AdminBooking['status'], string> = {
@@ -20,6 +30,34 @@ const STATUS_LABELS: Record<AdminBooking['status'], string> = {
completed: 'Terminée', completed: 'Terminée',
} }
const HAIR_LENGTH_LABELS: Record<string, string> = {
court: 'Court',
moyen: 'Moyen',
long: 'Long',
}
const HAIR_TYPE_LABELS: Record<string, string> = {
naturel: 'Naturel',
defrise: 'Défrisé',
extensions: 'Extensions déjà en place',
teints: 'Teint(s)',
}
const HAIR_STATE_LABELS: Record<string, string> = {
mouilles: 'Mouillés/lavés',
secs: 'Secs',
}
const EXTENSIONS_PROVIDED_BY_LABELS: Record<string, string> = {
cliente: 'La cliente',
salon: 'Le salon',
}
const BOOKING_COLUMNS =
'id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name), ' +
'hair_length, hair_type, scalp_sensitivities, hair_state, desired_style, extensions_provided_by, guest_count, ' +
'current_hair_photo_path, inspiration_photo_path'
const DAY_LABELS = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'] const DAY_LABELS = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']
function toISODate(date: Date): string { function toISODate(date: Date): string {
@@ -53,11 +91,13 @@ function AdminBookings() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date())) const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date()))
const [selectedDate, setSelectedDate] = useState<string | null>(null) const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [photoUrls, setPhotoUrls] = useState<Record<string, { current?: string; inspiration?: string }>>({})
async function loadBookings() { async function loadBookings() {
const { data, error: fetchError } = await supabase const { data, error: fetchError } = await supabase
.from('bookings') .from('bookings')
.select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)') .select(BOOKING_COLUMNS)
.order('date', { ascending: false }) .order('date', { ascending: false })
if (fetchError) { if (fetchError) {
setError(fetchError.message) setError(fetchError.message)
@@ -68,10 +108,7 @@ function AdminBookings() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const { data, error: fetchError } = await supabase const { data, error: fetchError } = await supabase.from('bookings').select(BOOKING_COLUMNS).order('date', { ascending: false })
.from('bookings')
.select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)')
.order('date', { ascending: false })
if (fetchError) { if (fetchError) {
setError(fetchError.message) setError(fetchError.message)
return return
@@ -81,6 +118,20 @@ function AdminBookings() {
void load() void load()
}, []) }, [])
async function toggleDetails(booking: AdminBooking) {
if (expandedId === booking.id) {
setExpandedId(null)
return
}
setExpandedId(booking.id)
if (photoUrls[booking.id]) return
const [current, inspiration] = await Promise.all([
booking.current_hair_photo_path ? getBookingPhotoSignedUrl(booking.current_hair_photo_path) : Promise.resolve(undefined),
booking.inspiration_photo_path ? getBookingPhotoSignedUrl(booking.inspiration_photo_path) : Promise.resolve(undefined),
])
setPhotoUrls((prev) => ({ ...prev, [booking.id]: { current, inspiration } }))
}
async function setStatus(id: string, status: AdminBooking['status']) { async function setStatus(id: string, status: AdminBooking['status']) {
const { error: updateError } = await supabase.from('bookings').update({ status }).eq('id', id) const { error: updateError } = await supabase.from('bookings').update({ status }).eq('id', id)
if (updateError) { if (updateError) {
@@ -180,46 +231,95 @@ function AdminBookings() {
</thead> </thead>
<tbody> <tbody>
{visibleBookings.map((booking) => ( {visibleBookings.map((booking) => (
<tr key={booking.id}> <Fragment key={booking.id}>
<td>{booking.date}</td> <tr>
<td>{booking.time}</td> <td>{booking.date}</td>
<td>{booking.services?.name}</td> <td>{booking.time}</td>
<td> <td>{booking.services?.name}</td>
<div>{booking.contact_name}</div> <td>
<div>{booking.contact_phone}</div> <div>{booking.contact_name}</div>
<div>{booking.contact_email}</div> <div>{booking.contact_phone}</div>
{booking.contact_notes && <div>{booking.contact_notes}</div>} <div>{booking.contact_email}</div>
</td> {booking.contact_notes && <div>{booking.contact_notes}</div>}
<td>{STATUS_LABELS[booking.status]}</td> </td>
<td> <td>{STATUS_LABELS[booking.status]}</td>
{booking.status === 'confirmed' && ( <td>
<> <button type="button" className={styles.buttonSecondary} onClick={() => void toggleDetails(booking)}>
<button {expandedId === booking.id ? 'Masquer les détails' : 'Voir les détails'}
type="button" </button>
className={styles.buttonDanger} {booking.status === 'confirmed' && (
onClick={() => { <>
if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) { <button
void setStatus(booking.id, 'cancelled') type="button"
} className={styles.buttonDanger}
}} onClick={() => {
> if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) {
Annuler void setStatus(booking.id, 'cancelled')
</button> }
<button }}
type="button" >
className={styles.buttonSecondary} Annuler
onClick={() => { </button>
if (window.confirm('Marquer cette réservation comme terminée ?')) { <button
void setStatus(booking.id, 'completed') type="button"
} className={styles.buttonSecondary}
}} onClick={() => {
> if (window.confirm('Marquer cette réservation comme terminée ?')) {
Marquer comme terminée void setStatus(booking.id, 'completed')
</button> }
</> }}
)} >
</td> Marquer comme terminée
</tr> </button>
</>
)}
</td>
</tr>
{expandedId === booking.id && (
<tr>
<td colSpan={6}>
<div className={styles.details}>
<div>
<strong>Longueur des cheveux :</strong>{' '}
{booking.hair_length ? HAIR_LENGTH_LABELS[booking.hair_length] : '—'}
</div>
<div>
<strong>Type de cheveux :</strong> {booking.hair_type ? HAIR_TYPE_LABELS[booking.hair_type] : '—'}
</div>
<div>
<strong>Cuir chevelu :</strong> {booking.scalp_sensitivities || '—'}
</div>
<div>
<strong>État des cheveux le jour J :</strong>{' '}
{booking.hair_state ? HAIR_STATE_LABELS[booking.hair_state] : '—'}
</div>
<div>
<strong>Style souhaité :</strong> {booking.desired_style || '—'}
</div>
<div>
<strong>Mèches/extensions fournies par :</strong>{' '}
{booking.extensions_provided_by ? EXTENSIONS_PROVIDED_BY_LABELS[booking.extensions_provided_by] : '—'}
</div>
<div>
<strong>Accompagnants :</strong> {booking.guest_count}
</div>
<div className={styles.detailPhotos}>
{photoUrls[booking.id]?.current && (
<a href={photoUrls[booking.id]?.current} target="_blank" rel="noreferrer">
<img src={photoUrls[booking.id]?.current} alt="Cheveux actuels" className={styles.detailPhoto} />
</a>
)}
{photoUrls[booking.id]?.inspiration && (
<a href={photoUrls[booking.id]?.inspiration} target="_blank" rel="noreferrer">
<img src={photoUrls[booking.id]?.inspiration} alt="Inspiration" className={styles.detailPhoto} />
</a>
)}
</div>
</div>
</td>
</tr>
)}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,26 @@
-- Mira asked for a few extra intake questions when a client books an
-- appointment (hair length/type, scalp sensitivities, desired style, who
-- provides extensions, guest count) plus two optional reference photos.
alter table public.bookings
add column hair_length text,
add column hair_type text,
add column scalp_sensitivities text not null default '',
add column hair_state text,
add column desired_style text not null default '',
add column extensions_provided_by text,
add column guest_count integer not null default 0,
add column current_hair_photo_path text,
add column inspiration_photo_path text;
-- Reference photos are private client photos, not marketing images, so
-- unlike salon-images this bucket stays private: anyone can upload (booking
-- is done by anonymous visitors) but only admin can read.
insert into storage.buckets (id, name, public)
values ('booking-photos', 'booking-photos', false)
on conflict (id) do update set public = false;
create policy "booking_photos_public_insert" on storage.objects
for insert with check (bucket_id = 'booking-photos');
create policy "booking_photos_admin_read" on storage.objects
for select using (bucket_id = 'booking-photos' and (select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid);