diff --git a/src/hooks/useBooking.test.ts b/src/hooks/useBooking.test.ts index f640176..5d35d45 100644 --- a/src/hooks/useBooking.test.ts +++ b/src/hooks/useBooking.test.ts @@ -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() diff --git a/src/hooks/useBooking.ts b/src/hooks/useBooking.ts index 8e72be2..c8ec777 100644 --- a/src/hooks/useBooking.ts +++ b/src/hooks/useBooking.ts @@ -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> +type BookingErrors = Partial> 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) => { + setState((s) => ({ ...s, intake: { ...s.intake, ...patch } })) + }, []) + const submit = useCallback(async (): Promise => { 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 } } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 04894aa..321984e 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -10,3 +10,18 @@ export async function uploadSalonImage(file: File, path: string): Promise { + 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 { + const { data, error } = await supabase.storage.from('booking-photos').createSignedUrl(path, 60 * 10) + if (error) throw error + return data.signedUrl +} diff --git a/src/pages/Book.module.css b/src/pages/Book.module.css index 4ff70ea..d7a0c50 100644 --- a/src/pages/Book.module.css +++ b/src/pages/Book.module.css @@ -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); +} diff --git a/src/pages/Book.test.tsx b/src/pages/Book.test.tsx index 38d80bb..62f48e1 100644 --- a/src/pages/Book.test.tsx +++ b/src/pages/Book.test.tsx @@ -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() diff --git a/src/pages/admin/AdminBookings.module.css b/src/pages/admin/AdminBookings.module.css index dceac08..ac10f18 100644 --- a/src/pages/admin/AdminBookings.module.css +++ b/src/pages/admin/AdminBookings.module.css @@ -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); +} diff --git a/src/pages/admin/AdminBookings.test.tsx b/src/pages/admin/AdminBookings.test.tsx index 98c32ca..935374a 100644 --- a/src/pages/admin/AdminBookings.test.tsx +++ b/src/pages/admin/AdminBookings.test.tsx @@ -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() + 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')) diff --git a/src/pages/admin/AdminBookings.tsx b/src/pages/admin/AdminBookings.tsx index 3435594..c5c0117 100644 --- a/src/pages/admin/AdminBookings.tsx +++ b/src/pages/admin/AdminBookings.tsx @@ -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 = { @@ -20,6 +30,34 @@ const STATUS_LABELS: Record = { completed: 'Terminée', } +const HAIR_LENGTH_LABELS: Record = { + court: 'Court', + moyen: 'Moyen', + long: 'Long', +} + +const HAIR_TYPE_LABELS: Record = { + naturel: 'Naturel', + defrise: 'Défrisé', + extensions: 'Extensions déjà en place', + teints: 'Teint(s)', +} + +const HAIR_STATE_LABELS: Record = { + mouilles: 'Mouillés/lavés', + secs: 'Secs', +} + +const EXTENSIONS_PROVIDED_BY_LABELS: Record = { + 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(null) + const [expandedId, setExpandedId] = useState(null) + const [photoUrls, setPhotoUrls] = useState>({}) 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() { {visibleBookings.map((booking) => ( - - {booking.date} - {booking.time} - {booking.services?.name} - -
{booking.contact_name}
-
{booking.contact_phone}
-
{booking.contact_email}
- {booking.contact_notes &&
{booking.contact_notes}
} - - {STATUS_LABELS[booking.status]} - - {booking.status === 'confirmed' && ( - <> - - - - )} - - + + + {booking.date} + {booking.time} + {booking.services?.name} + +
{booking.contact_name}
+
{booking.contact_phone}
+
{booking.contact_email}
+ {booking.contact_notes &&
{booking.contact_notes}
} + + {STATUS_LABELS[booking.status]} + + + {booking.status === 'confirmed' && ( + <> + + + + )} + + + {expandedId === booking.id && ( + + +
+
+ Longueur des cheveux :{' '} + {booking.hair_length ? HAIR_LENGTH_LABELS[booking.hair_length] : '—'} +
+
+ Type de cheveux : {booking.hair_type ? HAIR_TYPE_LABELS[booking.hair_type] : '—'} +
+
+ Cuir chevelu : {booking.scalp_sensitivities || '—'} +
+
+ État des cheveux le jour J :{' '} + {booking.hair_state ? HAIR_STATE_LABELS[booking.hair_state] : '—'} +
+
+ Style souhaité : {booking.desired_style || '—'} +
+
+ Mèches/extensions fournies par :{' '} + {booking.extensions_provided_by ? EXTENSIONS_PROVIDED_BY_LABELS[booking.extensions_provided_by] : '—'} +
+
+ Accompagnants : {booking.guest_count} +
+
+ {photoUrls[booking.id]?.current && ( + + Cheveux actuels + + )} + {photoUrls[booking.id]?.inspiration && ( + + Inspiration + + )} +
+
+ + + )} +
))} diff --git a/supabase/migrations/20260825103630_booking_intake_details.sql b/supabase/migrations/20260825103630_booking_intake_details.sql new file mode 100644 index 0000000..9b93de4 --- /dev/null +++ b/supabase/migrations/20260825103630_booking_intake_details.sql @@ -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);