From 7a3452c2d158e285b699921b66d276115aba9a51 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Mon, 27 Apr 2026 08:46:32 -0300 Subject: [PATCH] refactor: update home page UI to v2 patterns and introduce availability heatmap component --- .interface-design/system.md | 97 ++- .../components/ui/availability-heatmap.tsx | 113 +++ apps/frontend/src/features/home/home-page.tsx | 738 +++++++++--------- .../public-booking/public-booking-page.tsx | 60 +- 4 files changed, 543 insertions(+), 465 deletions(-) create mode 100644 apps/frontend/src/components/ui/availability-heatmap.tsx diff --git a/.interface-design/system.md b/.interface-design/system.md index df4ab95..68ab906 100644 --- a/.interface-design/system.md +++ b/.interface-design/system.md @@ -117,50 +117,73 @@ --- -## Home Page Patterns +## Home Page Patterns (v2) ### Structure -- Header: Title + DatePicker chip + Action button (3-column) -- Metrics Grid: 4-column stats with semantic colors -- Next Booking: Prominent card with emerald accent -- Timeline: Grouped by date with "now" indicator +- Header + URL Card: 2-column grid (lg:) +- Main: Timeline de reservas +- Sidebar: Stats grid consolidado (sticky) -### DatePicker Chip +### URL Card (destacada) ```tsx -
- - - - {isToday && ( - Hoy - )} -
-``` - -### Metrics Grid -```tsx -
-
-

Total

-

{value}

+
+
+
+
+

+ Reserva online +

+ + {url} + +
+
- // ... confirmed (emerald), completed (sky), no-show (orange)
``` -### Timeline Item +### Stats Pills (header) +```tsx + + {count} hoy + +``` + +### Stats Sidebar (grid 2x2) +```tsx +
+

+ Resumen +

+
+
+

Total

+

{value}

+
+
+

Hoy

+

{value}

+
+ {/* ... confirmed (blue), completed (sky) */} +
+
+``` + +### Timeline Item (v2 - mejorado) ```tsx
-
+
{startTime} - {endTime} + {endTime}
-

{courtName}

{customer}

@@ -169,18 +192,20 @@
- + +
``` -### Actions Hover Pattern +### Date Group Header ```tsx -
- - +
+

{dateLabel}

+
+ + {count} +
``` \ No newline at end of file diff --git a/apps/frontend/src/components/ui/availability-heatmap.tsx b/apps/frontend/src/components/ui/availability-heatmap.tsx new file mode 100644 index 0000000..f03e7a4 --- /dev/null +++ b/apps/frontend/src/components/ui/availability-heatmap.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +// Types for the component +type Slot = { + startTime: string; + endTime: string; +}; + +type Court = { + courtId: string; + courtName: string; + sport: { id: string; name: string; slug: string }; + slotDurationMinutes: number; + availableSlots: Slot[]; +}; + +type SelectedSlot = { + courtId: string; + startTime: string; +} | null; + +type AvailabilityHeatMapProps = { + courts: Court[]; + selectedSlot: SelectedSlot; + onSelectSlot: (payload: { + courtId: string; + courtName: string; + sportId: string; + sportName: string; + startTime: string; + endTime: string; + price?: { min: number; max: number }; + }) => void; +}; + +/** + * AvailabilityHeatMap + * + * A visual heat‑map style component that displays each court as a card and colours the + * slot buttons according to how many slots are available for that court. The colour scale + * goes from green (few slots) to red (many slots), providing an immediate visual cue of + * density – the signature element requested for the bookings panel. + */ +export default function AvailabilityHeatMap({ + courts, + selectedSlot, + onSelectSlot, +}: AvailabilityHeatMapProps) { + // Determine the maximum number of slots among all courts to normalise the heat scale. + const maxSlots = Math.max(1, ...courts.map((c) => c.availableSlots.length)); + + // Convert a slot count into an HSL colour ranging from green (low) to red (high). + const slotCountToHue = (count: number) => { + const ratio = count / maxSlots; // 0 => green, 1 => red + const hue = Math.round(120 - 120 * ratio); // 120° (green) to 0° (red) + return `hsl(${hue}, 70%, 55%)`; + }; + + return ( +
+ {courts.map((court) => { + const bg = slotCountToHue(court.availableSlots.length); + return ( +
+
+

{court.courtName}

+ + {court.availableSlots.length} horarios + +
+
+ {court.availableSlots.map((slot) => { + const isSelected = + selectedSlot?.courtId === court.courtId && + selectedSlot?.startTime === slot.startTime; + return ( + + ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index 7e3cb16..7cc7281 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -1,3 +1,4 @@ +import AvailabilityHeatMap from '@/components/ui/availability-heatmap'; import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; @@ -127,6 +128,9 @@ export function HomePage() { const [selectedSportId, setSelectedSportId] = useState(); const [selectedCourtId, setSelectedCourtId] = useState(''); const [selectedStartTime, setSelectedStartTime] = useState(''); + const [selectedSlot, setSelectedSlot] = useState<{ courtId: string; startTime: string } | null>( + null + ); const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); const [urlCopied, setUrlCopied] = useState(false); const [bookingToCancel, setBookingToCancel] = useState(null); @@ -310,6 +314,7 @@ export function HomePage() { ) { setSelectedCourtId(''); setSelectedStartTime(''); + setSelectedSlot(null); } }, [manualAvailabilityQuery.data, selectedCourtId]); @@ -320,11 +325,13 @@ export function HomePage() { useEffect(() => { if (!selectedCourt) { setSelectedStartTime(''); + setSelectedSlot(null); return; } if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) { setSelectedStartTime(''); + setSelectedSlot(null); } }, [selectedCourt, selectedStartTime]); @@ -418,6 +425,7 @@ export function HomePage() { reset(); setSelectedCourtId(''); setSelectedStartTime(''); + setSelectedSlot(null); setIsManualBookingOpen(false); await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); @@ -469,421 +477,401 @@ export function HomePage() { ); } - return ( -
-
-
-
-
-
-
- - Panel operativo -
-
-

- {selectedComplex.complexSlug} -

-

- Panel de reservas -

-

- Gestioná turnos del día y futuros para {selectedComplex.complexName}. -

-
-
- - {dashboardStats.today} hoy - - - {dashboardStats.confirmed} confirmadas - - - {dashboardStats.completed} cumplidas - -
-
+ const manualBookingDialog = ( + { + setIsManualBookingOpen(open); + if (!open) { + reset(); + setManualDate(todayIso); + setSelectedSportId(undefined); + setSelectedCourtId(''); + setSelectedStartTime(''); + setSelectedSlot(null); + } + }} + > + + + + e.preventDefault()} + > + + Reserva manual + + Crea un turno para atención telefónica o mostrador. + + -
-
-

- URL pública -

-
- - {publicBookingUrl} - - -
-
+
+
+ + Fecha + { + const isoDate = date ? toIsoDateLocal(date) : todayIso; + setManualDate(isoDate); + setSelectedCourtId(''); + setSelectedStartTime(''); + setSelectedSlot(null); + }} + minDate={new Date()} + placeholder="Selecciona una fecha" + /> + - { - setIsManualBookingOpen(open); - if (!open) { - reset(); - setManualDate(todayIso); - setSelectedSportId(undefined); - setSelectedCourtId(''); - setSelectedStartTime(''); - } + {manualAvailabilityQuery.data?.sportSelectionRequired && ( + + Deporte + + + )} - -
- - Fecha - { - const isoDate = date ? toIsoDateLocal(date) : todayIso; - setManualDate(isoDate); - setSelectedCourtId(''); - setSelectedStartTime(''); - }} - minDate={new Date()} - placeholder="Selecciona una fecha" - /> - + {manualAvailabilityQuery.data?.courts && ( + + Disponibilidad + { + setSelectedCourtId(courtId); + setSelectedStartTime(startTime); + setSelectedSlot({ courtId, startTime }); + }} + /> + + )} +
- {manualAvailabilityQuery.data?.sportSelectionRequired && ( - - Deporte - - - )} + {manualAvailabilityQuery.isLoading && ( +

Cargando horarios disponibles...

+ )} - - Cancha - - + {manualAvailabilityQuery.isError && ( +

+ {extractMessage( + manualAvailabilityQuery.error, + 'No pudimos cargar disponibilidad para la reserva manual.' + )} +

+ )} - - Horario - - -
+ + Nombre + + + - {manualAvailabilityQuery.isLoading && ( -

- Cargando horarios disponibles... -

- )} + + Teléfono + + + - {manualAvailabilityQuery.isError && ( -

- {extractMessage( - manualAvailabilityQuery.error, - 'No pudimos cargar disponibilidad para la reserva manual.' - )} -

- )} + {createManualBookingMutation.isError && ( +

+ {extractMessage( + createManualBookingMutation.error, + 'No pudimos crear la reserva manual.' + )} +

+ )} +
- - Nombre - - - + + + + + + + + + ); - - Teléfono - - - + const urlCard = ( +
+
+
+
+

+ Reserva online +

+ + {publicBookingUrl} + +
+ +
+
+ ); - {createManualBookingMutation.isError && ( -

- {extractMessage( - createManualBookingMutation.error, - 'No pudimos crear la reserva manual.' - )} -

- )} - + const headerPills = ( +
+ + {dashboardStats.today} hoy + + + {dashboardStats.confirmed} confirmadas + + + {dashboardStats.completed} cumplidas + +
+ ); - - - - - - - - + const dateSelector = ( +
+ + Desde + { + const isoDate = date ? toIsoDateLocal(date) : todayIso; + setFromDate(isoDate); + }} + disabled={(date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const checkDate = new Date(date); + checkDate.setHours(0, 0, 0, 0); + return checkDate < today; + }} + placeholder="Selecciona" + /> + +
+ ); + + return ( +
+
+
+
+
+
+ + {selectedComplex.complexSlug}
+
+

+ Panel de reservas +

+

+ Gestioná turnos de {selectedComplex.complexName} +

+
+ {headerPills}
-
-
- - Mostrar desde - { - const isoDate = date ? toIsoDateLocal(date) : todayIso; - setFromDate(isoDate); - }} - disabled={(date) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const checkDate = new Date(date); - checkDate.setHours(0, 0, 0, 0); - return checkDate < today; - }} - placeholder="Selecciona una fecha" - /> - -
+
+ {urlCard} + {manualBookingDialog} +
+
- {bookingsQuery.isLoading && ( -

Cargando reservas...

- )} +
+ {dateSelector} - {bookingsQuery.isError && ( -

- {extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')} -

- )} + {bookingsQuery.isLoading && ( +

Cargando reservas...

+ )} - {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && ( -

- No hay reservas para la fecha seleccionada en adelante. -

- )} + {bookingsQuery.isError && ( +

+ {extractMessage(bookingsQuery.error, 'No pudimos cargar las reservas.')} +

+ )} -
- {groupedBookings.map(([date, bookings]) => ( -
-
-

- {formatDateLabel(date, todayIso)} -

- - {bookings.length} reservas - -
-
- {bookings.map((booking) => { + {!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && ( +

+ No hay reservas para la fecha seleccionada. +

+ )} + +
+ {groupedBookings.map(([date, bookings]) => ( +
+
+

+ {formatDateLabel(date, todayIso)} +

+
+ + {bookings.length} + +
+ +
+ {bookings + .slice() + .sort((a, b) => a.startTime.localeCompare(b.startTime)) + .map((booking) => { const effectiveStatus = getEffectiveStatus(booking); const canManage = booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'; const isMutating = updateStatusMutation.isPending; + const now = new Date(); + const bookingStart = new Date(`${date}T${booking.startTime}:00`); + const bookingEnd = new Date(`${date}T${booking.endTime}:00`); + const isPast = bookingEnd < now; + const isNow = date === todayIso && bookingStart <= now && bookingEnd > now; + return (
-
-
-

- {booking.startTime} - {booking.endTime} · {booking.courtName} -

- +
+
+ + {booking.startTime} + + + {booking.endTime} + +
+ +
+
+

{booking.courtName}

+

+ {booking.customerName}{' '} + + · {booking.customerPhone} + +

+
-

- {booking.customerName} ({booking.customerPhone}) · Código{' '} - {booking.bookingCode} -

-
- {canManage && ( - <> - - - - )} +
+ +
+ {canManage && !isMutating && ( + <> + + + + )} +
); })} -
-
- ))} -
+
+
+ ))} +
- {updateStatusMutation.isError && ( -

- {extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')} -

- )} -
+ {updateStatusMutation.isError && ( +

+ {extractMessage(updateStatusMutation.error, 'No pudimos actualizar la reserva.')} +

+ )}
- -
{bookingToCancel && ( 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 53830cb..9f35935 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 AvailabilityHeatMap from '@/components/ui/availability-heatmap'; import { Button } from '@/components/ui/button'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; @@ -645,60 +646,11 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {

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

- {court.courtName} -

-

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

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