From c1d910708a2fc23d4106ad2192ab851352d9a9e1 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 19 May 2026 09:06:03 -0300 Subject: [PATCH] New UI for public booking --- .../public-booking/public-booking-page.tsx | 1467 ++++++++++++++--- apps/frontend/src/index.css | 42 + apps/frontend/src/routes/__root.tsx | 9 +- 3 files changed, 1250 insertions(+), 268 deletions(-) diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx index aba590a..60a6050 100644 --- a/apps/frontend/src/features/public-booking/public-booking-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx @@ -1,3 +1,4 @@ +import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png'; import { Button } from '@/components/ui/button'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; @@ -8,15 +9,45 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useIsMobile } from '@/hooks/use-mobile'; import { ApiClientError, apiClient } from '@/lib/api-client'; +import { useTheme } from '@/lib/theme'; import { zodResolver } from '@hookform/resolvers/zod'; +import type { PublicAvailabilityCourt, PublicBookingSport } from '@repo/api-contract'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; +import { + ArrowRight, + Building2, + CalendarDays, + Camera, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + Clock3, + Dumbbell, + Lock, + MapPin, + MessageCircle, + Moon, + Share2, + ShieldCheck, + Shirt, + Sparkles, + Sun, + Trophy, + Users, + Wrench, +} from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; const DAYS_STEP = 5; +const DESKTOP_DAYS_STEP = 7; +const BUSINESS_START_MINUTES = 8 * 60; +const BUSINESS_END_MINUTES = 22 * 60; const bookingFormSchema = z.object({ customerName: z @@ -46,6 +77,45 @@ type SelectedSlot = { endTime: string; }; +type BookingShellProps = { + complexName?: string; + complexAddress?: string; + sports: PublicBookingSport[]; + courts: PublicAvailabilityCourt[]; + selectedDate: string; + selectedSportId?: string; + selectedCourtId?: string; + selectedSlot: SelectedSlot | null; + dayOptions: DayOption[]; + canGoBack: boolean; + isLoading: boolean; + isError: boolean; + sportSelectionRequired?: boolean; + errorMessage?: string; + autoAdjustedDateNotice: string | null; + form: ReturnType>; + isCreating: boolean; + createError?: string; + navigationError: string | null; + onSelectDate: (date: string) => void; + onPreviousDays: () => void; + onNextDays: () => void; + onSelectSport: (sportId: string) => void; + onSelectCourt: (courtId: string) => void; + onSelectSlot: (slot: SelectedSlot) => void; + onSubmit: (values: BookingFormValues) => Promise; +}; + +type DayOption = { + value: string; + date: Date; + weekday: string; + dayMonth: string; + shortDay: string; + monthLabel: string; + longLabel: string; +}; + function addDays(baseDate: Date, amount: number) { const next = new Date(baseDate); next.setDate(next.getDate() + amount); @@ -64,16 +134,36 @@ function toMinutes(value: string): number { return hours * 60 + minutes; } -function formatDayLabel(date: Date) { - const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }).format(date); - const dayMonth = new Intl.DateTimeFormat('es-AR', { - day: '2-digit', - month: '2-digit', +function toTimeLabel(minutes: number) { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +function formatDayLabel(date: Date): DayOption { + const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }) + .format(date) + .replace('.', ''); + const monthLabel = new Intl.DateTimeFormat('es-AR', { month: 'short' }) + .format(date) + .replace('.', ''); + const longLabel = new Intl.DateTimeFormat('es-AR', { + weekday: 'long', + day: 'numeric', + month: 'long', }).format(date); return { - weekday: weekday.replace('.', ''), - dayMonth, + value: toIsoDateLocal(date), + date, + weekday, + dayMonth: new Intl.DateTimeFormat('es-AR', { + day: '2-digit', + month: '2-digit', + }).format(date), + shortDay: new Intl.DateTimeFormat('es-AR', { day: '2-digit' }).format(date), + monthLabel, + longLabel: longLabel.charAt(0).toUpperCase() + longLabel.slice(1), }; } @@ -85,27 +175,1050 @@ function extractMessage(error: unknown, fallback: string) { return fallback; } +function getStep(selectedSlot: SelectedSlot | null, isValid: boolean) { + if (!selectedSlot) return 1; + if (!isValid) return 3; + return 4; +} + +function getSelectedCourt( + courts: PublicAvailabilityCourt[], + selectedCourtId: string | undefined, + selectedSlot: SelectedSlot | null +) { + if (selectedSlot) { + return courts.find((court) => court.courtId === selectedSlot.courtId) ?? courts[0]; + } + + return courts.find((court) => court.courtId === selectedCourtId) ?? courts[0]; +} + +function getTimelineRange(court?: PublicAvailabilityCourt) { + if (!court || court.availableSlots.length === 0) { + return { + start: BUSINESS_START_MINUTES, + end: BUSINESS_END_MINUTES, + }; + } + + const starts = court.availableSlots.map((slot) => toMinutes(slot.startTime)); + const ends = court.availableSlots.map((slot) => toMinutes(slot.endTime)); + const firstStart = Math.min(...starts); + const lastEnd = Math.max(...ends); + + return { + start: Math.min(BUSINESS_START_MINUTES, Math.floor(firstStart / 60) * 60), + end: Math.max(BUSINESS_END_MINUTES, Math.ceil(lastEnd / 60) * 60), + }; +} + +function getHourTicks(start: number, end: number, compact = false) { + const step = compact ? 120 : 60; + const ticks = []; + + for (let minutes = start; minutes <= end; minutes += step) { + ticks.push(minutes); + } + + return ticks; +} + +function isSlotSelected( + slot: { startTime: string }, + courtId: string, + selectedSlot: SelectedSlot | null +) { + return selectedSlot?.courtId === courtId && selectedSlot.startTime === slot.startTime; +} + +function PublicBookingThemeToggle() { + const { resolvedTheme, toggleTheme } = useTheme(); + const Icon = resolvedTheme === 'dark' ? Sun : Moon; + + return ( + + ); +} + +function PlayzerBrand({ compact = false }: { compact?: boolean }) { + return ( +
+ Playzer + + Playzer + +
+ ); +} + +function ProgressSteps({ currentStep, mobile = false }: { currentStep: number; mobile?: boolean }) { + const labels = ['Elegi cancha', 'Selecciona horario', 'Tus datos', 'Confirmacion']; + + return ( +
+ {labels.map((label, index) => { + const step = index + 1; + const isActive = step <= currentStep; + + return ( +
+
+ {step} +
+ {!mobile && ( + + {label} + + )} + {index < labels.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} + +function Legend() { + return ( +
+ + + +
+ ); +} + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +function CourtIcon({ className = 'size-5' }: { className?: string }) { + return ; +} + +function PublicBookingPageChrome({ + children, + currentStep, + mobile = false, +}: { + children: React.ReactNode; + currentStep: number; + mobile?: boolean; +}) { + const { resolvedTheme } = useTheme(); + const themeClass = + resolvedTheme === 'dark' + ? 'public-booking-root public-booking-dark' + : 'public-booking-root public-booking-light'; + + if (mobile) { + return ( +
+
+
+ + +
+ +
+

+ Reserva tu cancha +
+ de forma rapida y sencilla +

+

+ Elegi tu cancha, selecciona el horario y completa tu reserva. +

+
+ +
+ +
+ + {children} + + +
+
+ ); + } + + return ( +
+
+
+ +
+ +
+

+ Reserva tu cancha +
+ de forma rapida y sencilla +

+

+ Elegi tu cancha, selecciona el horario y completa tu reserva. +

+
+ +
+
+ + {children} + +
+
+ +

© 2026 Playzer. Todos los derechos reservados.

+
+ + + + + + + + + +
+
+
+
+
+ ); +} + +function PublicBookingDesktop(props: BookingShellProps) { + const { + courts, + sports, + selectedSportId, + selectedCourtId, + selectedDate, + selectedSlot, + dayOptions, + canGoBack, + isLoading, + isError, + sportSelectionRequired, + errorMessage, + autoAdjustedDateNotice, + onPreviousDays, + onNextDays, + onSelectDate, + onSelectSport, + onSelectCourt, + onSelectSlot, + } = props; + const selectedCourt = getSelectedCourt(courts, selectedCourtId, selectedSlot); + const currentStep = getStep(selectedSlot, props.form.formState.isValid); + const selectedDay = dayOptions.find((option) => option.value === selectedDate); + + return ( + +
+ + +
+ {autoAdjustedDateNotice && ( +

+ {autoAdjustedDateNotice} +

+ )} + {isError && {errorMessage}} + {isLoading && Buscando disponibilidad...} + +
+ +
+ +
+ + {selectedDay?.longLabel ?? selectedDate} +
+ +
+
+ +
+ + +
+
+ +
+ + +
+ + +

Cambia de dia

+
+ + {dayOptions.map((option) => ( + + ))} + +
+
+
+
+
+ ); +} + +function PublicBookingMobile(props: BookingShellProps) { + const selectedCourt = getSelectedCourt(props.courts, props.selectedCourtId, props.selectedSlot); + const currentStep = getStep(props.selectedSlot, props.form.formState.isValid); + const selectedDay = props.dayOptions.find((option) => option.value === props.selectedDate); + + return ( + +
+ +

Selecciona tu cancha

+
+ {props.sportSelectionRequired && ( + ({ value: sport.id, label: sport.name }))} + /> + )} + + ({ + value: court.courtId, + label: court.courtName, + }))} + /> +
+
+ + + Fecha + +
+ + {selectedDay?.longLabel ?? props.selectedDate} +
+ +
+ + {props.autoAdjustedDateNotice && ( +

+ {props.autoAdjustedDateNotice} +

+ )} + {props.isLoading && Buscando disponibilidad...} + {props.isError && {props.errorMessage}} + +
+ +
+ +
+
+
+ +
+
+ +
+
+ + +
+ + {props.dayOptions.map((option) => ( + + ))} + +
+
+
+
+ ); +} + +function Panel({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +function StatusPanel({ children }: { children?: React.ReactNode }) { + return ( + +

{children}

+
+ ); +} + +function DarkSelect({ + label, + icon, + value, + placeholder, + options, + onValueChange, +}: { + label: string; + icon: React.ReactNode; + value: string; + placeholder: string; + options: { value: string; label: string }[]; + onValueChange: (value: string) => void; +}) { + return ( + + {label} + + + ); +} + +function ReadOnlySelect({ + label, + icon, + value, +}: { label: string; icon: React.ReactNode; value: string }) { + return ( +
+

{label}

+
+ {icon} + {value} + +
+
+ ); +} + +function MobileSelect({ + label, + value, + placeholder, + options, + onValueChange, +}: { + label: string; + value: string; + placeholder: string; + options: { value: string; label: string }[]; + onValueChange: (value: string) => void; +}) { + return ( +
+ {label} + +
+ ); +} + +function MobileReadOnlySelect({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + + +
+ ); +} + +function Feature({ icon, title, text }: { icon: React.ReactNode; title: string; text: string }) { + return ( +
+
{icon}
+
+

{title}

+

{text}

+
+
+ ); +} + +function CourtHeader({ + court, + complexName, + compact = false, +}: { + court?: PublicAvailabilityCourt; + complexName?: string; + compact?: boolean; +}) { + return ( +
+ {!compact && ( +
+ +
+ )} +
+

+ {court?.courtName ?? 'Cancha'} +

+

+ {court?.sport.name ?? 'Deporte'} {complexName ? ` · ${complexName}` : ''} +

+
+
+ ); +} + +function BookingTimeline({ + court, + selectedSlot, + onSelectSlot, + compact = false, +}: { + court?: PublicAvailabilityCourt; + selectedSlot: SelectedSlot | null; + onSelectSlot: (slot: SelectedSlot) => void; + compact?: boolean; +}) { + const range = getTimelineRange(court); + const ticks = getHourTicks(range.start, range.end, compact); + const total = range.end - range.start; + const slots = court?.availableSlots ?? []; + + return ( +
+
+
+ {ticks.map((tick) => ( +
+ {toTimeLabel(tick)} +
+ ))} +
+
+ {ticks.map((tick) => ( + + ))} + {slots.length > 0 ? ( + slots.map((slot) => { + const left = ((toMinutes(slot.startTime) - range.start) / total) * 100; + const width = ((toMinutes(slot.endTime) - toMinutes(slot.startTime)) / total) * 100; + const selected = court ? isSlotSelected(slot, court.courtId, selectedSlot) : false; + + return ( + + ); + }) + ) : ( +
+ Sin horarios disponibles +
+ )} + {slots.slice(0, compact ? 2 : 3).map((slot, index) => { + const end = toMinutes(slot.endTime); + const nextStart = slots[index + 1] ? toMinutes(slots[index + 1].startTime) : end + 60; + if (nextStart - end < 45 || nextStart > range.end) return null; + + const left = ((end - range.start) / total) * 100; + const width = ((Math.min(nextStart, range.end) - end) / total) * 100; + const maintenance = index % 2 === 1; + + return ( +
+ {maintenance ? : } +
+ ); + })} + {selectedSlot && court?.courtId === selectedSlot.courtId && ( +
+ + {selectedSlot.startTime} + + +
+ )} +
+
+
+ ); +} + +function CourtDetails({ court }: { court?: PublicAvailabilityCourt }) { + return ( + +
+
+ +
+
+

+ {court?.courtName ? `${court.courtName} de ${court.sport.name}` : 'Cancha'} +

+
+

+ + Superficie: Cesped sintetico +

+

+ + Turnos de {court?.slotDurationMinutes ?? 60} minutos +

+

+ + Iluminacion LED +

+

+ + Vestuarios disponibles +

+
+
+
+
+ ); +} + +function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) { + const { + selectedSlot, + form, + isCreating, + createError, + navigationError, + onSubmit, + compact = false, + } = props; + const { + register, + handleSubmit, + formState: { errors, isSubmitting, isValid }, + } = form; + + return ( + +
+
+ +
+
+

{selectedSlot ? 'Hora libre!' : 'Elegi un horario'}

+

+ {selectedSlot + ? `Este horario esta disponible para reservar. ${selectedSlot.startTime} - ${selectedSlot.endTime}` + : 'Selecciona una cancha y un horario disponible.'} +

+
+
+ + {selectedSlot && ( +
+
+ + + Nombre + + + + + + + + Telefono + + + + +
+ + {createError &&

{createError}

} + {navigationError &&

{navigationError}

} + + +
+ )} +
+ ); +} + +function MobileBottomNav() { + const items = [ + { label: 'Reservar', icon: CalendarDays, active: true }, + { label: 'Canchas', icon: Building2 }, + { label: 'Como funciona', icon: Clock3 }, + { label: 'Contacto', icon: Users }, + ]; + + return ( + + ); +} + export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { const navigate = useNavigate(); + const isMobile = useIsMobile(); const today = useMemo(() => new Date(), []); const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today]); const [windowStartOffset, setWindowStartOffset] = useState(0); const [selectedSportId, setSelectedSportId] = useState(); + const [selectedCourtId, setSelectedCourtId] = useState(); const [selectedSlot, setSelectedSlot] = useState(null); const [navigationError, setNavigationError] = useState(null); const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false); const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState(null); + const daysToShow = isMobile ? DAYS_STEP : DESKTOP_DAYS_STEP; const dayOptions = useMemo(() => { - return Array.from({ length: DAYS_STEP }).map((_, index) => { + return Array.from({ length: daysToShow }).map((_, index) => { const current = addDays(today, windowStartOffset + index); - return { - value: toIsoDateLocal(current), - date: current, - ...formatDayLabel(current), - }; + return formatDayLabel(current); }); - }, [today, windowStartOffset]); + }, [daysToShow, today, windowStartOffset]); const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? ''); @@ -143,12 +1256,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { } }, [availabilityQuery.data, selectedSportId]); - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting, isValid }, - } = useForm({ + const form = useForm({ resolver: zodResolver(bookingFormSchema), mode: 'onChange', defaultValues: { @@ -165,7 +1273,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { return apiClient.publicBookings.create(complexSlug, { date: selectedDate, - sportId: selectedSportId, + sportId: selectedSportId ?? selectedSlot.sportId, courtId: selectedSlot.courtId, startTime: selectedSlot.startTime, customerName: payload.customerName, @@ -183,7 +1291,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { return; } - reset(); + form.reset(); setSelectedSlot(null); await availabilityQuery.refetch(); await navigate({ @@ -229,6 +1337,19 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { } }, [selectedSlot, visibleCourts]); + useEffect(() => { + if (visibleCourts.length === 0) { + setSelectedCourtId(undefined); + return; + } + + if (selectedCourtId && visibleCourts.some((court) => court.courtId === selectedCourtId)) { + return; + } + + setSelectedCourtId(visibleCourts[0]?.courtId); + }, [selectedCourtId, visibleCourts]); + useEffect(() => { if (hasAutoAdjustedInitialDate) return; if (selectedDate !== todayIsoDate) { @@ -239,10 +1360,6 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { return; } - if (availabilityQuery.data.sportSelectionRequired && !selectedSportId) { - return; - } - if (visibleCourts.length > 0) { setHasAutoAdjustedInitialDate(true); return; @@ -304,11 +1421,17 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { const canGoBack = windowStartOffset > 0; - const goToPreviousFiveDays = () => { + const selectDate = (date: string) => { + setSelectedDate(date); + setSelectedSlot(null); + setAutoAdjustedDateNotice(null); + }; + + const goToPreviousDays = () => { if (!canGoBack) return; setWindowStartOffset((previous) => { - const next = Math.max(0, previous - DAYS_STEP); + const next = Math.max(0, previous - daysToShow); const nextStartDate = toIsoDateLocal(addDays(today, next)); setSelectedDate(nextStartDate); setSelectedSlot(null); @@ -317,9 +1440,9 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { }); }; - const goToNextFiveDays = () => { + const goToNextDays = () => { setWindowStartOffset((previous) => { - const next = previous + DAYS_STEP; + const next = previous + daysToShow; const nextStartDate = toIsoDateLocal(addDays(today, next)); setSelectedDate(nextStartDate); setSelectedSlot(null); @@ -328,243 +1451,53 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) { }); }; - return ( -
-
-
-
-

- {availabilityQuery.data?.complexName} -

- {availabilityQuery.data?.complexAddress && ( -

- {availabilityQuery.data.complexAddress} -

- )} -
-

- Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono. -

-
+ const shellProps: BookingShellProps = { + complexName: availabilityQuery.data?.complexName, + complexAddress: availabilityQuery.data?.complexAddress, + sports: availabilityQuery.data?.sports ?? [], + courts: visibleCourts, + selectedDate, + selectedSportId, + selectedCourtId, + selectedSlot, + dayOptions, + canGoBack, + isLoading: availabilityQuery.isLoading, + isError: availabilityQuery.isError, + sportSelectionRequired: availabilityQuery.data?.sportSelectionRequired, + errorMessage: availabilityQuery.isError + ? extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.') + : undefined, + autoAdjustedDateNotice, + form, + isCreating: createBookingMutation.isPending, + createError: createBookingMutation.isError + ? extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.') + : undefined, + navigationError, + onSelectDate: selectDate, + onPreviousDays: goToPreviousDays, + onNextDays: goToNextDays, + onSelectSport: (sportId) => { + setSelectedSportId(sportId); + setSelectedCourtId(undefined); + setSelectedSlot(null); + setAutoAdjustedDateNotice(null); + }, + onSelectCourt: (courtId) => { + setSelectedCourtId(courtId); + setSelectedSlot(null); + }, + onSelectSlot: (slot) => { + setSelectedCourtId(slot.courtId); + setSelectedSlot(slot); + }, + onSubmit, + }; -
-
-
-

Dia

-

Mostramos 5 dias por vez.

-
- {autoAdjustedDateNotice && ( -

- {autoAdjustedDateNotice} -

- )} - -
- {canGoBack ? ( - - ) : ( -
- )} - - {dayOptions.map((option) => ( - - ))} - - -
-
-
- -
-
-
-

Disponibilidad

-

Selecciona cancha y horario.

-
-
- - {availabilityQuery.data?.sportSelectionRequired && ( -
- - Deporte - - -
- )} - - {availabilityQuery.isLoading && ( -

Buscando disponibilidad...

- )} - - {availabilityQuery.isError && ( -

- {extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')} -

- )} - - {!availabilityQuery.isLoading && - !availabilityQuery.isError && - availabilityQuery.data?.sportSelectionRequired && - !selectedSportId && ( -

- Selecciona un deporte para ver los horarios disponibles. -

- )} - - {!availabilityQuery.isLoading && - !availabilityQuery.isError && - availabilityQuery.data && - (!availabilityQuery.data.sportSelectionRequired || selectedSportId) && - visibleCourts.length === 0 && ( -

- No hay horarios disponibles para la fecha elegida. -

- )} - -
- {visibleCourts.map((court) => ( -
-
-

{court.courtName}

-

- {court.sport.name} · Turnos de {court.slotDurationMinutes} min -

-
- -
- {court.availableSlots.map((slot) => { - const isSelected = - selectedSlot?.courtId === court.courtId && - selectedSlot.startTime === slot.startTime; - - return ( - - ); - })} -
-
- ))} -
-
- - {selectedSlot && ( -
-

Confirmar turno

-

- {selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '} - {selectedSlot.endTime} -

- -
- - Nombre - - - - - - Telefono - - - - - {createBookingMutation.isError && ( -

- {extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')} -

- )} - {navigationError &&

{navigationError}

} - - -
-
- )} -
-
+ return isMobile ? ( + + ) : ( + ); } diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 328efd3..369493e 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -244,4 +244,46 @@ body, border: 3px solid transparent; background-clip: padding-box; } + + .public-booking-light { + background: #eaf2f4; + color: #0f172a; + } + + .public-booking-light .public-booking-frame { + background: radial-gradient(circle at 18% 10%, rgba(16, 185, 129, 0.18), transparent 28%), + radial-gradient(circle at 82% 18%, rgba(59, 130, 246, 0.14), transparent 30%), + linear-gradient(180deg, #f8fbfc 0%, #edf5f7 52%, #e5eef2 100%); + } + + .public-booking-light .public-booking-panel { + background: rgba(255, 255, 255, 0.78); + border-color: rgba(15, 23, 42, 0.13); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 48px rgba(15, 23, 42, 0.09); + } + + .public-booking-light [class*="border-white"] { + border-color: rgba(15, 23, 42, 0.13); + } + + .public-booking-light [class*="bg-white"] { + background-color: rgba(15, 23, 42, 0.045); + } + + .public-booking-light [class*="text-white"] { + color: rgba(15, 23, 42, 0.72); + } + + .public-booking-light .text-emerald-400, + .public-booking-light .text-cyan-300 { + color: #059669; + } + + .public-booking-light .bg-emerald-500 { + background-color: #10b981; + } + + .public-booking-light .text-red-300 { + color: #dc2626; + } } diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 5b659c4..ec07dda 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -3,7 +3,7 @@ import { NotFoundPage } from '@/features/not-found/not-found-page'; import { ServerErrorPage } from '@/features/server-error/server-error-page'; import type { ApiClient } from '@/lib/api-client'; import type { AuthContextValue } from '@/lib/auth'; -import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; +import { Outlet, createRootRouteWithContext, useLocation } from '@tanstack/react-router'; type RouterContext = { auth: AuthContextValue; @@ -17,6 +17,13 @@ export const Route = createRootRouteWithContext()({ }); function RootRoute() { + const location = useLocation(); + const isPublicBookingRoute = /^\/[^/]+\/booking(?:\/|$)/.test(location.pathname); + + if (isPublicBookingRoute) { + return ; + } + return (