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:
@@ -3,17 +3,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const insertMock = vi.fn()
|
||||
const fromMock = vi.fn<(table: string) => { insert: typeof insertMock }>(() => ({ insert: insertMock }))
|
||||
const uploadBookingPhotoMock = vi.fn()
|
||||
|
||||
vi.mock('../lib/supabase', () => ({
|
||||
supabase: { from: (table: string) => fromMock(table) },
|
||||
}))
|
||||
|
||||
vi.mock('../lib/storage', () => ({
|
||||
uploadBookingPhoto: (file: File, path: string) => uploadBookingPhotoMock(file, path),
|
||||
}))
|
||||
|
||||
import useBooking from './useBooking'
|
||||
|
||||
describe('useBooking', () => {
|
||||
beforeEach(() => {
|
||||
insertMock.mockReset().mockResolvedValue({ error: null })
|
||||
fromMock.mockClear()
|
||||
uploadBookingPhotoMock.mockReset().mockImplementation((_file: File, path: string) => Promise.resolve(path))
|
||||
})
|
||||
|
||||
it('starts at the service step, or date step if preselected', () => {
|
||||
@@ -54,11 +60,62 @@ describe('useBooking', () => {
|
||||
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 () => {
|
||||
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' }))
|
||||
act(() => result.current.updateIntake({ hairLength: 'long', hairType: 'naturel', hairState: 'secs', extensionsProvidedBy: 'salon' }))
|
||||
|
||||
let success = false
|
||||
await act(async () => {
|
||||
@@ -74,6 +131,15 @@ describe('useBooking', () => {
|
||||
contact_phone: '5551234567',
|
||||
contact_email: 'jane@example.com',
|
||||
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(result.current.state.step).toBe('confirmed')
|
||||
@@ -85,6 +151,7 @@ describe('useBooking', () => {
|
||||
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' }))
|
||||
act(() => result.current.updateIntake({ hairLength: 'long', hairType: 'naturel', hairState: 'secs', extensionsProvidedBy: 'salon' }))
|
||||
|
||||
let success = true
|
||||
await act(async () => {
|
||||
@@ -103,6 +170,7 @@ describe('useBooking', () => {
|
||||
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' }))
|
||||
act(() => result.current.updateIntake({ hairLength: 'long', hairType: 'naturel', hairState: 'secs', extensionsProvidedBy: 'salon' }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submit()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'
|
||||
import type { TimeSlot } from '../data/availability'
|
||||
import { isValidEmail } from '../lib/validation'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { uploadBookingPhoto } from '../lib/storage'
|
||||
|
||||
export interface BookingContact {
|
||||
name: string
|
||||
@@ -10,6 +11,23 @@ export interface BookingContact {
|
||||
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 interface BookingState {
|
||||
@@ -18,12 +36,27 @@ export interface BookingState {
|
||||
date: string | null
|
||||
slot: TimeSlot | null
|
||||
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'
|
||||
|
||||
function initialIntake(): BookingIntake {
|
||||
return {
|
||||
hairLength: '',
|
||||
hairType: '',
|
||||
scalpSensitivities: '',
|
||||
hairState: '',
|
||||
desiredStyle: '',
|
||||
extensionsProvidedBy: '',
|
||||
guestCount: 0,
|
||||
currentHairPhoto: null,
|
||||
inspirationPhoto: null,
|
||||
}
|
||||
}
|
||||
|
||||
function initialState(initialServiceId?: string): BookingState {
|
||||
return {
|
||||
step: initialServiceId ? 'date' : 'service',
|
||||
@@ -31,6 +64,7 @@ function initialState(initialServiceId?: string): BookingState {
|
||||
date: null,
|
||||
slot: null,
|
||||
contact: { name: '', phone: '', email: '', notes: '' },
|
||||
intake: initialIntake(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +90,10 @@ export default function useBooking(initialServiceId?: string) {
|
||||
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> => {
|
||||
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 (!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.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)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
return false
|
||||
@@ -73,29 +115,53 @@ export default function useBooking(initialServiceId?: string) {
|
||||
|
||||
setSubmitting(true)
|
||||
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) {
|
||||
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)
|
||||
try {
|
||||
const photoStamp = `${state.date}-${state.slot.time}-${Date.now()}`
|
||||
const currentHairPhotoPath = state.intake.currentHairPhoto
|
||||
? await uploadBookingPhoto(state.intake.currentHairPhoto, `${photoStamp}-current.jpg`)
|
||||
: null
|
||||
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' }))
|
||||
return true
|
||||
setState((s) => ({ ...s, step: 'confirmed' }))
|
||||
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])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
@@ -105,5 +171,5 @@ export default function useBooking(initialServiceId?: string) {
|
||||
setSubmitting(false)
|
||||
}, [initialServiceId])
|
||||
|
||||
return { state, errors, submitError, submitting, selectService, selectDate, selectSlot, updateContact, submit, reset }
|
||||
return { state, errors, submitError, submitting, selectService, selectDate, selectSlot, updateContact, updateIntake, submit, reset }
|
||||
}
|
||||
|
||||
@@ -10,3 +10,18 @@ export async function uploadSalonImage(file: File, path: string): Promise<string
|
||||
if (error) throw error
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,3 +22,17 @@
|
||||
color: #a0453a;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ describe('Book page', () => {
|
||||
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.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 waitFor(() =>
|
||||
@@ -113,6 +117,15 @@ describe('Book page', () => {
|
||||
contact_phone: '5551234567',
|
||||
contact_email: 'jane@example.com',
|
||||
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()
|
||||
|
||||
@@ -113,3 +113,24 @@
|
||||
background: white;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,16 @@ const selectMock = vi.fn()
|
||||
const eqUpdateMock = vi.fn()
|
||||
const updateMock = vi.fn()
|
||||
const fromMock = vi.fn()
|
||||
const getBookingPhotoSignedUrlMock = vi.fn()
|
||||
|
||||
vi.mock('../../lib/supabase', () => ({
|
||||
supabase: { from: (...args: unknown[]) => fromMock(...args) },
|
||||
}))
|
||||
|
||||
vi.mock('../../lib/storage', () => ({
|
||||
getBookingPhotoSignedUrl: (path: string) => getBookingPhotoSignedUrlMock(path),
|
||||
}))
|
||||
|
||||
const BOOKING = {
|
||||
id: 'booking-1',
|
||||
date: '2026-09-15',
|
||||
@@ -22,6 +27,15 @@ const BOOKING = {
|
||||
contact_notes: '',
|
||||
status: 'confirmed',
|
||||
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'
|
||||
@@ -33,6 +47,7 @@ describe('AdminBookings', () => {
|
||||
eqUpdateMock.mockReset().mockResolvedValue({ error: null })
|
||||
updateMock.mockReset().mockReturnValue({ eq: eqUpdateMock })
|
||||
fromMock.mockReset().mockReturnValue({ select: selectMock, update: updateMock })
|
||||
getBookingPhotoSignedUrlMock.mockReset().mockResolvedValue('https://signed.example.com/photo.jpg')
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
@@ -86,6 +101,22 @@ describe('AdminBookings', () => {
|
||||
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 () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date('2026-09-01T12:00:00'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Fragment, useEffect, useState } from 'react'
|
||||
import { supabase } from '../../lib/supabase'
|
||||
import { getBookingPhotoSignedUrl } from '../../lib/storage'
|
||||
import styles from './AdminBookings.module.css'
|
||||
|
||||
interface AdminBooking {
|
||||
@@ -12,6 +13,15 @@ interface AdminBooking {
|
||||
contact_notes: string
|
||||
status: 'confirmed' | 'cancelled' | 'completed'
|
||||
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> = {
|
||||
@@ -20,6 +30,34 @@ const STATUS_LABELS: Record<AdminBooking['status'], string> = {
|
||||
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']
|
||||
|
||||
function toISODate(date: Date): string {
|
||||
@@ -53,11 +91,13 @@ function AdminBookings() {
|
||||
const [error, setError] = useState('')
|
||||
const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date()))
|
||||
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() {
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('bookings')
|
||||
.select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)')
|
||||
.select(BOOKING_COLUMNS)
|
||||
.order('date', { ascending: false })
|
||||
if (fetchError) {
|
||||
setError(fetchError.message)
|
||||
@@ -68,10 +108,7 @@ function AdminBookings() {
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('bookings')
|
||||
.select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)')
|
||||
.order('date', { ascending: false })
|
||||
const { data, error: fetchError } = await supabase.from('bookings').select(BOOKING_COLUMNS).order('date', { ascending: false })
|
||||
if (fetchError) {
|
||||
setError(fetchError.message)
|
||||
return
|
||||
@@ -81,6 +118,20 @@ function AdminBookings() {
|
||||
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']) {
|
||||
const { error: updateError } = await supabase.from('bookings').update({ status }).eq('id', id)
|
||||
if (updateError) {
|
||||
@@ -180,46 +231,95 @@ function AdminBookings() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleBookings.map((booking) => (
|
||||
<tr key={booking.id}>
|
||||
<td>{booking.date}</td>
|
||||
<td>{booking.time}</td>
|
||||
<td>{booking.services?.name}</td>
|
||||
<td>
|
||||
<div>{booking.contact_name}</div>
|
||||
<div>{booking.contact_phone}</div>
|
||||
<div>{booking.contact_email}</div>
|
||||
{booking.contact_notes && <div>{booking.contact_notes}</div>}
|
||||
</td>
|
||||
<td>{STATUS_LABELS[booking.status]}</td>
|
||||
<td>
|
||||
{booking.status === 'confirmed' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.buttonDanger}
|
||||
onClick={() => {
|
||||
if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) {
|
||||
void setStatus(booking.id, 'cancelled')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.buttonSecondary}
|
||||
onClick={() => {
|
||||
if (window.confirm('Marquer cette réservation comme terminée ?')) {
|
||||
void setStatus(booking.id, 'completed')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Marquer comme terminée
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={booking.id}>
|
||||
<tr>
|
||||
<td>{booking.date}</td>
|
||||
<td>{booking.time}</td>
|
||||
<td>{booking.services?.name}</td>
|
||||
<td>
|
||||
<div>{booking.contact_name}</div>
|
||||
<div>{booking.contact_phone}</div>
|
||||
<div>{booking.contact_email}</div>
|
||||
{booking.contact_notes && <div>{booking.contact_notes}</div>}
|
||||
</td>
|
||||
<td>{STATUS_LABELS[booking.status]}</td>
|
||||
<td>
|
||||
<button type="button" className={styles.buttonSecondary} onClick={() => void toggleDetails(booking)}>
|
||||
{expandedId === booking.id ? 'Masquer les détails' : 'Voir les détails'}
|
||||
</button>
|
||||
{booking.status === 'confirmed' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.buttonDanger}
|
||||
onClick={() => {
|
||||
if (window.confirm('Annuler cette réservation ? Cela libérera son créneau pour d\'autres réservations.')) {
|
||||
void setStatus(booking.id, 'cancelled')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.buttonSecondary}
|
||||
onClick={() => {
|
||||
if (window.confirm('Marquer cette réservation comme terminée ?')) {
|
||||
void setStatus(booking.id, 'completed')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Marquer comme terminée
|
||||
</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>
|
||||
</table>
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user