From c1d910708a2fc23d4106ad2192ab851352d9a9e1 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 19 May 2026 09:06:03 -0300 Subject: [PATCH 1/8] 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 (
From e905052cc199b4c9f290ef2d716ad23879cb60d1 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 19 May 2026 09:21:21 -0300 Subject: [PATCH 2/8] Added Stepper component --- apps/frontend/src/components/ui/stepper.tsx | 1206 +++++++++++++++++ .../public-booking/public-booking-page.tsx | 83 +- apps/frontend/src/lib/compose-refs.ts | 61 + 3 files changed, 1310 insertions(+), 40 deletions(-) create mode 100644 apps/frontend/src/components/ui/stepper.tsx create mode 100644 apps/frontend/src/lib/compose-refs.ts diff --git a/apps/frontend/src/components/ui/stepper.tsx b/apps/frontend/src/components/ui/stepper.tsx new file mode 100644 index 0000000..7ae14b3 --- /dev/null +++ b/apps/frontend/src/components/ui/stepper.tsx @@ -0,0 +1,1206 @@ +'use client'; + +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; +import { useComposedRefs } from '@/lib/compose-refs'; +import { cn } from '@/lib/utils'; +import { Check } from 'lucide-react'; +import { Direction as DirectionPrimitive, Slot as SlotPrimitive } from 'radix-ui'; +import * as React from 'react'; + +const ROOT_NAME = 'Stepper'; +const LIST_NAME = 'StepperList'; +const ITEM_NAME = 'StepperItem'; +const TRIGGER_NAME = 'StepperTrigger'; +const INDICATOR_NAME = 'StepperIndicator'; +const SEPARATOR_NAME = 'StepperSeparator'; +const TITLE_NAME = 'StepperTitle'; +const DESCRIPTION_NAME = 'StepperDescription'; +const CONTENT_NAME = 'StepperContent'; +const PREV_NAME = 'StepperPrev'; +const NEXT_NAME = 'StepperNext'; + +const ENTRY_FOCUS = 'stepperFocusGroup.onEntryFocus'; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; +const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + +type Direction = 'ltr' | 'rtl'; +type Orientation = 'horizontal' | 'vertical'; +type NavigationDirection = 'next' | 'prev'; +type ActivationMode = 'automatic' | 'manual'; +type DataState = 'inactive' | 'active' | 'completed'; + +interface DivProps extends React.ComponentProps<'div'> { + asChild?: boolean; +} +interface ButtonProps extends React.ComponentProps<'button'> { + asChild?: boolean; +} + +type ListElement = React.ComponentRef; +type TriggerElement = React.ComponentRef; + +function getId( + id: string, + variant: 'trigger' | 'content' | 'title' | 'description', + value: string +) { + return `${id}-${variant}-${value}`; +} + +type FocusIntent = 'first' | 'last' | 'prev' | 'next'; + +const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowLeft: 'prev', + ArrowUp: 'prev', + ArrowRight: 'next', + ArrowDown: 'next', + PageUp: 'first', + Home: 'first', + PageDown: 'last', + End: 'last', +}; + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') return key; + return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key; +} + +function getFocusIntent( + event: React.KeyboardEvent, + dir?: Direction, + orientation?: Orientation +) { + const key = getDirectionAwareKey(event.key, dir); + if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) return undefined; + if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) return undefined; + return MAP_KEY_TO_FOCUS_INTENT[key]; +} + +function focusFirst(candidates: React.RefObject[], preventScroll = false) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidateRef of candidates) { + const candidate = candidateRef.current; + if (!candidate) continue; + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length] as T); +} + +function getDataState( + value: string | undefined, + itemValue: string, + stepState: StepState | undefined, + steps: Map, + variant: 'item' | 'separator' = 'item' +): DataState { + const stepKeys = Array.from(steps.keys()); + const currentIndex = stepKeys.indexOf(itemValue); + + if (stepState?.completed) return 'completed'; + + if (value === itemValue) { + return variant === 'separator' ? 'inactive' : 'active'; + } + + if (value) { + const activeIndex = stepKeys.indexOf(value); + + if (activeIndex > currentIndex) return 'completed'; + } + + return 'inactive'; +} + +interface StepState { + value: string; + completed: boolean; + disabled: boolean; +} + +interface StoreState { + steps: Map; + value: string; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + setState: (key: K, value: StoreState[K]) => void; + setStateWithValidation: (value: string, direction: NavigationDirection) => Promise; + hasValidation: () => boolean; + notify: () => void; + addStep: (value: string, completed: boolean, disabled: boolean) => void; + removeStep: (value: string) => void; + setStep: (value: string, completed: boolean, disabled: boolean) => void; +} + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +function useStore(selector: (state: StoreState) => T): T { + const store = useStoreContext('useStore'); + + const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface ItemData { + id: string; + ref: React.RefObject; + value: string; + active: boolean; + disabled: boolean; +} + +interface StepperContextValue { + rootId: string; + dir: Direction; + orientation: Orientation; + activationMode: ActivationMode; + disabled: boolean; + nonInteractive: boolean; + loop: boolean; +} + +const StepperContext = React.createContext(null); + +function useStepperContext(consumerName: string) { + const context = React.useContext(StepperContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface StepperProps extends DivProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + onValueComplete?: (value: string, completed: boolean) => void; + onValueAdd?: (value: string) => void; + onValueRemove?: (value: string) => void; + onValidate?: (value: string, direction: NavigationDirection) => boolean | Promise; + activationMode?: ActivationMode; + dir?: Direction; + orientation?: Orientation; + disabled?: boolean; + loop?: boolean; + nonInteractive?: boolean; +} + +function Stepper(props: StepperProps) { + const { + value, + defaultValue, + onValueChange, + onValueComplete, + onValueAdd, + onValueRemove, + onValidate, + dir: dirProp, + orientation = 'horizontal', + activationMode = 'automatic', + asChild, + disabled = false, + nonInteractive = false, + loop = false, + className, + id, + ...rootProps + } = props; + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => ({ + steps: new Map(), + value: value ?? defaultValue ?? '', + })); + + const propsRef = useAsRef({ + onValueChange, + onValueComplete, + onValueAdd, + onValueRemove, + onValidate, + }); + + const store = React.useMemo(() => { + return { + subscribe: (cb) => { + listenersRef.current.add(cb); + return () => listenersRef.current.delete(cb); + }, + getState: () => stateRef.current, + setState: (key, value) => { + if (Object.is(stateRef.current[key], value)) return; + + if (key === 'value' && typeof value === 'string') { + stateRef.current.value = value; + propsRef.current.onValueChange?.(value); + } else { + stateRef.current[key] = value; + } + + store.notify(); + }, + setStateWithValidation: async (value, direction) => { + if (!propsRef.current.onValidate) { + store.setState('value', value); + return true; + } + + try { + const isValid = await propsRef.current.onValidate(value, direction); + if (isValid) { + store.setState('value', value); + } + return isValid; + } catch { + return false; + } + }, + hasValidation: () => !!propsRef.current.onValidate, + addStep: (value, completed, disabled) => { + const newStep: StepState = { value, completed, disabled }; + stateRef.current.steps.set(value, newStep); + propsRef.current.onValueAdd?.(value); + store.notify(); + }, + removeStep: (value) => { + stateRef.current.steps.delete(value); + propsRef.current.onValueRemove?.(value); + store.notify(); + }, + setStep: (value, completed, disabled) => { + const step = stateRef.current.steps.get(value); + if (step) { + const updatedStep: StepState = { ...step, completed, disabled }; + stateRef.current.steps.set(value, updatedStep); + + if (completed !== step.completed) { + propsRef.current.onValueComplete?.(value, completed); + } + + store.notify(); + } + }, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + }; + }, [listenersRef, stateRef, propsRef]); + + useIsomorphicLayoutEffect(() => { + if (value !== undefined) { + store.setState('value', value); + } + }, [value]); + + const dir = DirectionPrimitive.useDirection(dirProp); + + const instanceId = React.useId(); + const rootId = id ?? instanceId; + + const contextValue = React.useMemo( + () => ({ + rootId, + dir, + orientation, + activationMode, + disabled, + nonInteractive, + loop, + }), + [rootId, dir, orientation, activationMode, disabled, nonInteractive, loop] + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + + + ); +} + +interface FocusContextValue { + tabStopId: string | null; + onItemFocus: (tabStopId: string) => void; + onItemShiftTab: () => void; + onFocusableItemAdd: () => void; + onFocusableItemRemove: () => void; + onItemRegister: (item: ItemData) => void; + onItemUnregister: (id: string) => void; + getItems: () => ItemData[]; +} + +const FocusContext = React.createContext(null); + +function useFocusContext(consumerName: string) { + const context = React.useContext(FocusContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`FocusProvider\``); + } + return context; +} + +function StepperList(props: DivProps) { + const { + asChild, + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + className, + children, + ref, + ...listProps + } = props; + + const context = useStepperContext(LIST_NAME); + const orientation = context.orientation; + const currentValue = useStore((state) => state.value); + + const propsRef = useAsRef({ + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + }); + + const [tabStopId, setTabStopId] = React.useState(null); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const [focusableItemCount, setFocusableItemCount] = React.useState(0); + const isClickFocusRef = React.useRef(false); + const itemsRef = React.useRef>(new Map()); + const listRef = React.useRef(null); + const composedRef = useComposedRefs(ref, listRef); + + const onItemFocus = React.useCallback((tabStopId: string) => { + setTabStopId(tabStopId); + }, []); + + const onItemShiftTab = React.useCallback(() => { + setIsTabbingBackOut(true); + }, []); + + const onFocusableItemAdd = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount + 1); + }, []); + + const onFocusableItemRemove = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount - 1); + }, []); + + const onItemRegister = React.useCallback((item: ItemData) => { + itemsRef.current.set(item.id, item); + }, []); + + const onItemUnregister = React.useCallback((id: string) => { + itemsRef.current.delete(id); + }, []); + + const getItems = React.useCallback(() => { + return Array.from(itemsRef.current.values()) + .filter((item) => item.ref.current) + .sort((a, b) => { + const elementA = a.ref.current; + const elementB = b.ref.current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + }, []); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onBlur?.(event); + if (event.defaultPrevented) return; + + setIsTabbingBackOut(false); + }, + [propsRef] + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + const isKeyboardFocus = !isClickFocusRef.current; + if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = Array.from(itemsRef.current.values()).filter((item) => !item.disabled); + const selectedItem = currentValue + ? items.find((item) => item.value === currentValue) + : undefined; + const activeItem = items.find((item) => item.active); + const currentItem = items.find((item) => item.id === tabStopId); + + const candidateItems = [selectedItem, activeItem, currentItem, ...items].filter( + Boolean + ) as ItemData[]; + const candidateRefs = candidateItems.map((item) => item.ref); + focusFirst(candidateRefs, false); + } + } + isClickFocusRef.current = false; + }, + [propsRef, isTabbingBackOut, currentValue, tabStopId] + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + + if (event.defaultPrevented) return; + + isClickFocusRef.current = true; + }, + [propsRef] + ); + + const focusContextValue = React.useMemo( + () => ({ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + }), + [ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + ] + ); + + const ListPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + {children} + + + ); +} + +interface StepperItemContextValue { + value: string; + stepState: StepState | undefined; +} + +const StepperItemContext = React.createContext(null); + +function useStepperItemContext(consumerName: string) { + const context = React.useContext(StepperItemContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); + } + return context; +} + +interface StepperItemProps extends DivProps { + value: string; + completed?: boolean; + disabled?: boolean; +} + +function StepperItem(props: StepperItemProps) { + const { + value: itemValue, + completed = false, + disabled = false, + asChild, + className, + children, + ref, + ...itemProps + } = props; + + const context = useStepperContext(ITEM_NAME); + const store = useStoreContext(ITEM_NAME); + const orientation = context.orientation; + const value = useStore((state) => state.value); + + useIsomorphicLayoutEffect(() => { + store.addStep(itemValue, completed, disabled); + + return () => { + store.removeStep(itemValue); + }; + }, [itemValue, completed, disabled]); + + useIsomorphicLayoutEffect(() => { + store.setStep(itemValue, completed, disabled); + }, [itemValue, completed, disabled]); + + const stepState = useStore((state) => state.steps.get(itemValue)); + const steps = useStore((state) => state.steps); + const dataState = getDataState(value, itemValue, stepState, steps); + + const itemContextValue = React.useMemo( + () => ({ + value: itemValue, + stepState, + }), + [itemValue, stepState] + ); + + const ItemPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + {children} + + + ); +} + +function StepperTrigger(props: ButtonProps) { + const { + asChild, + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + disabled, + className, + ref, + ...triggerProps + } = props; + + const context = useStepperContext(TRIGGER_NAME); + const itemContext = useStepperItemContext(TRIGGER_NAME); + const itemValue = itemContext.value; + + const store = useStoreContext(TRIGGER_NAME); + const focusContext = useFocusContext(TRIGGER_NAME); + const value = useStore((state) => state.value); + const steps = useStore((state) => state.steps); + const stepState = useStore((state) => state.steps.get(itemValue)); + + const propsRef = useAsRef({ + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + }); + + const activationMode = context.activationMode; + const orientation = context.orientation; + const loop = context.loop; + + const stepIndex = Array.from(steps.keys()).indexOf(itemValue); + + const stepPosition = stepIndex + 1; + const stepCount = steps.size; + + const triggerId = getId(context.rootId, 'trigger', itemValue); + const contentId = getId(context.rootId, 'content', itemValue); + const titleId = getId(context.rootId, 'title', itemValue); + const descriptionId = getId(context.rootId, 'description', itemValue); + + const isDisabled = disabled || stepState?.disabled || context.disabled; + const isActive = value === itemValue; + const isTabStop = focusContext.tabStopId === triggerId; + const dataState = getDataState(value, itemValue, stepState, steps); + + const triggerRef = React.useRef(null); + const composedRef = useComposedRefs(ref, triggerRef); + const isArrowKeyPressedRef = React.useRef(false); + const isMouseClickRef = React.useRef(false); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (ARROW_KEYS.includes(event.key)) { + isArrowKeyPressedRef.current = true; + } + } + function onKeyUp() { + isArrowKeyPressedRef.current = false; + } + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + }; + }, []); + + useIsomorphicLayoutEffect(() => { + focusContext.onItemRegister({ + id: triggerId, + ref: triggerRef, + value: itemValue, + active: isTabStop, + disabled: !!isDisabled, + }); + + if (!isDisabled) { + focusContext.onFocusableItemAdd(); + } + + return () => { + focusContext.onItemUnregister(triggerId); + if (!isDisabled) { + focusContext.onFocusableItemRemove(); + } + }; + }, [focusContext, triggerId, itemValue, isTabStop, isDisabled]); + + const onClick = React.useCallback( + async (event: React.MouseEvent) => { + propsRef.current.onClick?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !context.nonInteractive) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value ?? ''); + const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue); + const direction = targetStepIndex > currentStepIndex ? 'next' : 'prev'; + + await store.setStateWithValidation(itemValue, direction); + } + }, + [isDisabled, context.nonInteractive, store, itemValue, value, steps, propsRef] + ); + + const onFocus = React.useCallback( + async (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + focusContext.onItemFocus(triggerId); + + const isKeyboardFocus = !isMouseClickRef.current; + + if ( + !isActive && + !isDisabled && + activationMode !== 'manual' && + !context.nonInteractive && + isKeyboardFocus + ) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value || ''); + const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue); + const direction = targetStepIndex > currentStepIndex ? 'next' : 'prev'; + + await store.setStateWithValidation(itemValue, direction); + } + + isMouseClickRef.current = false; + }, + [ + focusContext, + triggerId, + activationMode, + isActive, + isDisabled, + context.nonInteractive, + store, + itemValue, + value, + steps, + propsRef, + ] + ); + + const onKeyDown = React.useCallback( + async (event: React.KeyboardEvent) => { + propsRef.current.onKeyDown?.(event); + if (event.defaultPrevented) return; + + if (event.key === 'Enter' && context.nonInteractive) { + event.preventDefault(); + return; + } + + if ( + (event.key === 'Enter' || event.key === ' ') && + activationMode === 'manual' && + !context.nonInteractive + ) { + event.preventDefault(); + if (!isDisabled && triggerRef.current) { + triggerRef.current.click(); + } + return; + } + + if (event.key === 'Tab' && event.shiftKey) { + focusContext.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) return; + + const focusIntent = getFocusIntent(event, context.dir, orientation); + + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + event.preventDefault(); + + const items = focusContext.getItems().filter((item) => !item.disabled); + let candidateRefs = items.map((item) => item.ref); + + if (focusIntent === 'last') { + candidateRefs.reverse(); + } else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') candidateRefs.reverse(); + const currentIndex = candidateRefs.findIndex( + (ref) => ref.current === event.currentTarget + ); + candidateRefs = loop + ? wrapArray(candidateRefs, currentIndex + 1) + : candidateRefs.slice(currentIndex + 1); + } + + if (store.hasValidation() && candidateRefs.length > 0) { + const nextRef = candidateRefs[0]; + const nextElement = nextRef?.current; + const nextItem = items.find((item) => item.ref.current === nextElement); + + if (nextItem && nextItem.value !== itemValue) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value || ''); + const targetStepIndex = Array.from(steps.keys()).indexOf(nextItem.value); + const direction: NavigationDirection = + targetStepIndex > currentStepIndex ? 'next' : 'prev'; + + if (direction === 'next') { + const isValid = await store.setStateWithValidation(nextItem.value, direction); + if (!isValid) return; + } else { + store.setState('value', nextItem.value); + } + + queueMicrotask(() => nextElement?.focus()); + return; + } + } + + queueMicrotask(() => focusFirst(candidateRefs)); + } + }, + [ + focusContext, + context.nonInteractive, + context.dir, + activationMode, + orientation, + loop, + isDisabled, + store, + propsRef, + itemValue, + value, + steps, + ] + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + if (event.defaultPrevented) return; + + isMouseClickRef.current = true; + + if (isDisabled) { + event.preventDefault(); + } else { + focusContext.onItemFocus(triggerId); + } + }, + [focusContext, triggerId, isDisabled, propsRef] + ); + + const TriggerPrimitive = asChild ? SlotPrimitive.Slot : 'button'; + + return ( + + ); +} + +interface StepperIndicatorProps extends Omit { + children?: React.ReactNode | ((dataState: DataState) => React.ReactNode); +} + +function StepperIndicator(props: StepperIndicatorProps) { + const { className, children, asChild, ref, ...indicatorProps } = props; + + const context = useStepperContext(INDICATOR_NAME); + const itemContext = useStepperItemContext(INDICATOR_NAME); + + const value = useStore((state) => state.value); + const itemValue = itemContext.value; + const stepState = useStore((state) => state.steps.get(itemValue)); + const steps = useStore((state) => state.steps); + + const stepPosition = Array.from(steps.keys()).indexOf(itemValue) + 1; + + const dataState = getDataState(value, itemValue, stepState, steps); + + const IndicatorPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + {typeof children === 'function' ? ( + children(dataState) + ) : children ? ( + children + ) : dataState === 'completed' ? ( + + ) : ( + stepPosition + )} + + ); +} + +interface StepperSeparatorProps extends DivProps { + forceMount?: boolean; +} + +function StepperSeparator(props: StepperSeparatorProps) { + const { className, asChild, forceMount = false, ref, ...separatorProps } = props; + + const context = useStepperContext(SEPARATOR_NAME); + const itemContext = useStepperItemContext(SEPARATOR_NAME); + const value = useStore((state) => state.value); + const steps = useStore((state) => state.steps); + + const orientation = context.orientation; + + const stepIndex = Array.from(steps.keys()).indexOf(itemContext.value); + + const isLastStep = stepIndex === steps.size - 1; + + if (isLastStep && !forceMount) return null; + + const dataState = getDataState( + value, + itemContext.value, + itemContext.stepState, + steps, + 'separator' + ); + + const SeparatorPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( +