diff --git a/apps/backend/src/lib/http/problem-mapper.ts b/apps/backend/src/lib/http/problem-mapper.ts index b4be4a1..7b97a10 100644 --- a/apps/backend/src/lib/http/problem-mapper.ts +++ b/apps/backend/src/lib/http/problem-mapper.ts @@ -15,6 +15,7 @@ function statusFromError(error: AppError): ContentfulStatusCode { case 'conflict': return 409; case 'unexpected': + return 500; default: return 500; } diff --git a/apps/frontend/src/components/header.tsx b/apps/frontend/src/components/header.tsx index ccce9d3..6de3f7d 100644 --- a/apps/frontend/src/components/header.tsx +++ b/apps/frontend/src/components/header.tsx @@ -143,7 +143,7 @@ export function Header() {
-
+
Playzer diff --git a/apps/frontend/src/components/layout.tsx b/apps/frontend/src/components/layout.tsx index 7f46592..5392e60 100644 --- a/apps/frontend/src/components/layout.tsx +++ b/apps/frontend/src/components/layout.tsx @@ -1,3 +1,5 @@ +import { useIsMobile } from '@/hooks/use-mobile'; +import { useLocation } from '@tanstack/react-router'; import type { ReactNode } from 'react'; import { Header } from './header'; @@ -6,11 +8,21 @@ interface LayoutProps { } export function Layout({ children }: LayoutProps) { + const isMobile = useIsMobile(); + const location = useLocation(); + const isMobileHome = isMobile && location.pathname === '/'; + return (
-
-
+ {!isMobileHome &&
} +
{children}
diff --git a/apps/frontend/src/features/about/about-page.tsx b/apps/frontend/src/features/about/about-page.tsx index 71f61de..1cd4854 100644 --- a/apps/frontend/src/features/about/about-page.tsx +++ b/apps/frontend/src/features/about/about-page.tsx @@ -1,6 +1,6 @@ export function AboutPage() { return ( -
+

About

diff --git a/apps/frontend/src/features/booking/booking.tsx b/apps/frontend/src/features/booking/booking.tsx index 004f07d..c2000e1 100644 --- a/apps/frontend/src/features/booking/booking.tsx +++ b/apps/frontend/src/features/booking/booking.tsx @@ -1,8 +1,10 @@ +import { useIsMobile } from '@/hooks/use-mobile'; import type { ComplexWithRole } from '@repo/api-contract'; import { BookingProvider } from './booking-provider'; import { BookingCreateDialog } from './components/booking-create-dialog'; import { BookingDaySummary, BookingQuickActions } from './components/booking-day-summary'; import { BookingHeader } from './components/booking-header'; +import { BookingMobile } from './components/booking-mobile'; import { BookingTimeline } from './components/booking-timeline'; import { BookingToolbar } from './components/booking-toolbar'; import { BookingToolsDialog } from './components/booking-tools-dialog'; @@ -12,17 +14,23 @@ interface BookingProps { } export function Booking({ complex }: BookingProps) { + const isMobile = useIsMobile(); + return ( -

- - - -
- - + {isMobile ? ( + + ) : ( +
+ + + +
+ + +
-
+ )} diff --git a/apps/frontend/src/features/booking/components/booking-create-dialog.tsx b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx index 4e180bc..a108c34 100644 --- a/apps/frontend/src/features/booking/components/booking-create-dialog.tsx +++ b/apps/frontend/src/features/booking/components/booking-create-dialog.tsx @@ -68,6 +68,7 @@ export function BookingCreateDialog() { register, handleSubmit, reset, + setFocus, formState: { errors, isValid }, } = useForm({ resolver: zodResolver(bookingFormSchema), @@ -86,7 +87,13 @@ export function BookingCreateDialog() { setCourtId(selectedSlot?.courtId ?? ''); setStartTime(selectedSlot?.startTime ?? ''); reset(); - }, [isCreateBookingOpen, reset, selectedDate, selectedSlot]); + + if (selectedSlot?.courtId && selectedSlot?.startTime) { + requestAnimationFrame(() => { + setFocus('customerName'); + }); + } + }, [isCreateBookingOpen, reset, selectedDate, selectedSlot, setFocus]); const sports = [...new Map(courts.map((court) => [court.sport.id, court.sport])).values()]; const filteredCourts = useMemo(() => { diff --git a/apps/frontend/src/features/booking/components/booking-mobile.tsx b/apps/frontend/src/features/booking/components/booking-mobile.tsx new file mode 100644 index 0000000..3c4f4bd --- /dev/null +++ b/apps/frontend/src/features/booking/components/booking-mobile.tsx @@ -0,0 +1,776 @@ +import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png'; +import { Button } from '@/components/ui/button'; +import { DatePicker } from '@/components/ui/date-picker'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { UserAvatar } from '@/components/user-avatar'; +import { apiClient } from '@/lib/api-client'; +import { useAuth } from '@/lib/auth'; +import { useCurrentComplexStore } from '@/lib/stores/current-complex-store'; +import { useTheme } from '@/lib/theme'; +import { cn } from '@/lib/utils'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { + CalendarDays, + Check, + ChevronLeft, + ChevronRight, + CircleDot, + Dumbbell, + Grid2X2, + Laptop, + ListChecks, + LogOut, + Moon, + MoreHorizontal, + Plus, + Search, + Settings, + Sun, + User, + Users, + Wrench, +} from 'lucide-react'; +import type React from 'react'; +import { useMemo, useState } from 'react'; +import { useBooking } from '../booking-provider'; +import type { BookingCourtSchedule, BookingTimelineSegment } from '../booking.types'; +import { fromIsoDateLocal, toIsoDateLocal } from '../lib/booking-time'; + +export function BookingMobile() { + const { schedules, isLoading, isError, errorMessage } = useBooking(); + const [selectedCourtId, setSelectedCourtId] = useState(null); + const [search, setSearch] = useState(''); + + const selectedSchedule = useMemo(() => { + if (!selectedCourtId) return null; + return schedules.find((schedule) => schedule.court.id === selectedCourtId) ?? null; + }, [schedules, selectedCourtId]); + const filteredSchedules = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return schedules; + + return schedules.filter((schedule) => { + return ( + schedule.court.name.toLowerCase().includes(query) || + schedule.court.sport.name.toLowerCase().includes(query) + ); + }); + }, [schedules, search]); + + if (selectedSchedule) { + return setSelectedCourtId(null)} />; + } + + return ( +
+ +
+ + + + +
+

Canchas

+
+ + setSearch(event.target.value)} + /> +
+ + {isLoading && ( +
+ Cargando reservas... +
+ )} + + {isError && !isLoading && ( +
+ {errorMessage} +
+ )} + + {!isLoading && !isError && filteredSchedules.length === 0 && ( +
+ No hay canchas para los filtros seleccionados. +
+ )} + + {!isLoading && + !isError && + filteredSchedules.map((schedule) => ( + setSelectedCourtId(schedule.court.id)} + /> + ))} +
+
+ +
+ ); +} + +function MobileTopBar() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { user, isAuthenticated, displayName, avatarUrl, signOut } = useAuth(); + const { currentComplexSlug, setCurrentComplex } = useCurrentComplexStore(); + const { theme, setTheme } = useTheme(); + const { openCreateBooking } = useBooking(); + const [open, setOpen] = useState(false); + + const myComplexesQuery = useQuery({ + queryKey: ['my-complexes'], + enabled: isAuthenticated, + queryFn: () => apiClient.complexes.listMine(), + }); + + const complexSlug = currentComplexSlug || myComplexesQuery.data?.[0]?.complexSlug; + const currentComplexName = useMemo(() => { + const complexes = myComplexesQuery.data ?? []; + if (complexes.length === 0) return 'Mi complejo'; + + const selected = complexes.find((complex) => complex.complexSlug === complexSlug); + return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'; + }, [complexSlug, myComplexesQuery.data]); + + const handleSelectComplex = async (complexId: string, nextSlug: string) => { + await apiClient.complexes.select({ complexId }); + setCurrentComplex(nextSlug); + await queryClient.invalidateQueries({ queryKey: ['current-complex'] }); + await queryClient.invalidateQueries({ queryKey: ['admin-bookings'] }); + }; + + const handleSignOut = async () => { + setOpen(false); + await signOut(); + await navigate({ to: '/login' }); + }; + + return ( +
+
+
+ Playzer + Playzer +
+ + + + + +
+
+
+ +
+

{displayName}

+

{user?.email}

+

{currentComplexName}

+
+
+
+ + + + + +
+
+

+ Cuenta +

+
+ + +
+
+
+
+
+
+ ); +} + +function MobilePanelHeader() { + const { complex, openCreateBooking } = useBooking(); + + return ( +
+
+

Panel de Reservas

+

+ Gestiona las canchas de {complex.complexName} +

+
+ +
+ ); +} + +function MobileFilters() { + const { selectedDate, setSelectedDate } = useBooking(); + + return ( +
+ + setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date())) + } + className="h-12 w-full justify-center rounded-lg border-border/70 bg-card/75 px-4 text-sm font-medium" + placeholder="Fecha" + /> +
+ ); +} + +function MobileSummary() { + const { summary } = useBooking(); + + return ( +
+

Resumen del día

+
+ + + +
+
+ ); +} + +function SummaryPill({ + value, + label, + className, +}: { + value: number; + label: string; + className: string; +}) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +function MobileCourtCard({ + schedule, + onOpen, +}: { + schedule: BookingCourtSchedule; + onOpen: () => void; +}) { + const nextSegments = schedule.segments.slice(0, 5); + + return ( +
+
+ +
+
+
+

{schedule.court.name}

+

{schedule.court.sport.name}

+
+ +
+
+
+ +

Próximos horarios

+
+ {nextSegments.map((segment) => ( + + ))} +
+ + +
+ ); +} + +function MobileCourtDay({ + schedule, + onBack, +}: { schedule: BookingCourtSchedule; onBack: () => void }) { + const { selectedDate, moveSelectedDate, setSelectedDate, openCreateBooking } = useBooking(); + + return ( +
+
+
+ +
+

{schedule.court.name}

+

{schedule.court.sport.name}

+
+ +
+ +
+ + + setSelectedDate(date ? toIsoDateLocal(date) : toIsoDateLocal(new Date())) + } + className="h-12 rounded-lg border-border/70 bg-card/75 text-sm" + /> + +
+
+ +
+
+ +
+ +
+ {schedule.segments.map((segment) => ( + + ))} +
+
+ +
+ +
+
+ ); +} + +function MobileSegmentRow({ + schedule, + segment, +}: { + schedule: BookingCourtSchedule; + segment: BookingTimelineSegment; +}) { + const { openCreateBooking, openBookingTools } = useBooking(); + const isFree = segment.status === 'free'; + + return ( + + ); +} + +function SegmentStatusLine({ segment }: { segment: BookingTimelineSegment }) { + const status = segment.status; + const label = status === 'free' ? 'Libre' : status === 'reserved' ? 'Reservado' : 'Mantenimiento'; + + return ( +

+ + {label} +

+ ); +} + +function StatusLegend() { + return ( +
+ + + +
+ ); +} + +function LegendItem({ label, className }: { label: string; className: string }) { + return ( + + + {label} + + ); +} + +function MiniSlot({ + schedule, + segment, +}: { + schedule: BookingCourtSchedule; + segment: BookingTimelineSegment; +}) { + const { openCreateBooking, openBookingTools } = useBooking(); + const label = + segment.status === 'free' ? 'Libre' : segment.status === 'reserved' ? 'Reservado' : 'Manten.'; + const isActionable = segment.status === 'free' || Boolean(segment.booking); + + return ( + + ); +} + +function SportIcon({ sportName }: { sportName: string }) { + const isPadel = sportName.toLowerCase().includes('padel'); + + return ( +
+ {isPadel ? : } +
+ ); +} + +function MetricDots({ schedule }: { schedule: BookingCourtSchedule }) { + return ( +
+ + + {schedule.metrics.free} + + + + {schedule.metrics.reserved} + + + + {schedule.metrics.maintenance} + +
+ ); +} + +function MobileBottomNav({ active }: { active: 'panel' | 'status' | 'bookings' | 'more' }) { + return ( + + ); +} + +function BottomNavItem({ + active, + icon, + label, +}: { active: boolean; icon: React.ReactNode; label: string }) { + return ( + + ); +} diff --git a/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx b/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx index f396bca..50b221a 100644 --- a/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx +++ b/apps/frontend/src/features/booking/components/booking-tools-dialog.tsx @@ -9,16 +9,19 @@ import { ResponsiveDialogTitle, } from '@/components/ui/responsive-dialog'; import { Separator } from '@/components/ui/separator'; +import { useIsMobile } from '@/hooks/use-mobile'; import { cn } from '@/lib/utils'; import { AlertTriangle, BadgeCheck, + CalendarDays, CheckCircle2, Clock, MapPin, MessageCircle, Phone, User, + Wrench, XCircle, } from 'lucide-react'; import { useBooking } from '../booking-provider'; @@ -65,36 +68,68 @@ const statusConfig: Record = { export function BookingToolsDialog() { const { selectedSegment, bookingToolsOpen, closeBookingTools, updateBookingStatus } = useBooking(); + const isMobile = useIsMobile(); + const booking = selectedSegment?.booking; - const status = statusConfig[selectedSegment?.booking?.status ?? 'CONFIRMED'] ?? { - label: selectedSegment?.booking?.status, + const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? { + label: booking?.status, className: 'bg-slate-500/15 text-slate-300 border-slate-500/30', }; const sendWhatsappReminder = () => { - const phone = selectedSegment?.booking?.customerPhone; + const phone = booking?.customerPhone; + if (!phone) return; + const message = ` - Hola ${selectedSegment?.booking?.customerName}. \nTe recordamos que tenés una reserva para el día *${formatDate(selectedSegment?.booking?.date)} de ${selectedSegment?.booking?.startTime} a ${selectedSegment?.booking?.endTime} en la cancha ${selectedSegment?.booking?.courtName} (${selectedSegment?.booking?.sport?.name})*. ¡Te esperamos! \nSi no vas a poder asistir, por favor avisanos para que otros clientes puedan reservar. Gracias.\n\n*Equipo de ${selectedSegment?.booking?.complexName}*`; + Hola ${booking?.customerName}. \nTe recordamos que tenés una reserva para el día *${formatDate(booking?.date)} de ${booking?.startTime} a ${booking?.endTime} en la cancha ${booking?.courtName} (${booking?.sport?.name})*. ¡Te esperamos! \nSi no vas a poder asistir, por favor avisanos para que otros clientes puedan reservar. Gracias.\n\n*Equipo de ${booking?.complexName}*`; const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`; window.open(url, '_blank'); }; + const updateStatus = (nextStatus: 'CANCELLED' | 'COMPLETED' | 'NOSHOW') => { + if (!booking?.id) return; + updateBookingStatus(booking.id, nextStatus); + closeBookingTools(); + }; + + if (isMobile) { + return ( + !open && closeBookingTools()} + > + event.preventDefault()} + > + updateStatus('COMPLETED')} + onCancel={() => updateStatus('CANCELLED')} + onReminder={sendWhatsappReminder} + onNoShow={() => updateStatus('NOSHOW')} + /> + + + ); + } + return ( !open && closeBookingTools()}> event.preventDefault()} > - + Detalles de la reserva Administra la reserva y su estado. -
-
+
+
} label="Cancha" @@ -122,7 +157,7 @@ export function BookingToolsDialog() { label="Fecha" value={`${formatDate(selectedSegment?.booking?.date)}`} withDivider - className="pt-10" + className="sm:pt-10" /> } label="Teléfono" value={selectedSegment?.booking?.customerPhone ?? '-'} - className="pt-10" + className="sm:pt-10" />
@@ -156,10 +191,9 @@ export function BookingToolsDialog() { icon={} title="Completar reserva" description="El cliente se presentó para usar la cancha." - className="border-emerald-500/40 bg-emerald-500/10 text-emerald-600 text-lg hover:bg-emerald-500/15 hover:text-esmerald-800" + className="border-emerald-500/40 bg-emerald-500/10 text-lg text-emerald-600 hover:bg-emerald-500/15 hover:text-emerald-800" onClick={() => { - updateBookingStatus(selectedSegment?.booking?.id!, 'COMPLETED'); - closeBookingTools(); + updateStatus('COMPLETED'); }} /> @@ -167,10 +201,9 @@ export function BookingToolsDialog() { icon={} title="Cancelar reserva" description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible." - className="border-red-500/40 bg-red-500/10 text-red-600 text-lg hover:bg-red-500/15 hover:text-red-800" + className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800" onClick={() => { - updateBookingStatus(selectedSegment?.booking?.id!, 'CANCELLED'); - closeBookingTools(); + updateStatus('CANCELLED'); }} /> @@ -178,7 +211,7 @@ export function BookingToolsDialog() { icon={} title="Enviar recordatorio" description="Enviar un mensaje por WhatsApp al cliente." - className="border-sky-500/40 bg-sky-500/10 text-sky-600 text-lg hover:bg-sky-500/15 hover:text-sky-800" + className="border-sky-500/40 bg-sky-500/10 text-lg text-sky-600 hover:bg-sky-500/15 hover:text-sky-800" onClick={sendWhatsappReminder} /> @@ -186,18 +219,19 @@ export function BookingToolsDialog() { icon={} title="Marcar como No Show" description="El cliente no vino y no canceló." - className="border-amber-500/40 bg-amber-500/10 text-amber-600 text-lg hover:bg-amber-500/15 hover:text-amber-800" + className="border-amber-500/40 bg-amber-500/10 text-lg text-amber-600 hover:bg-amber-500/15 hover:text-amber-800" onClick={() => { - updateBookingStatus(selectedSegment?.booking?.id!, 'NOSHOW'); - closeBookingTools(); + updateStatus('NOSHOW'); }} />
- + - +
@@ -205,6 +239,176 @@ export function BookingToolsDialog() { ); } +function MobileBookingToolsSheet({ + status, + onComplete, + onCancel, + onReminder, + onNoShow, +}: { + status: { label: string | undefined; className: string }; + onComplete: () => void; + onCancel: () => void; + onReminder: () => void; + onNoShow: () => void; +}) { + const { selectedSegment } = useBooking(); + const booking = selectedSegment?.booking; + + return ( +
+ +
+
+ Reserva + + {booking?.courtName ?? '-'} · {booking?.startTime ?? '--:--'} a{' '} + {booking?.endTime ?? '--:--'} + +
+ + {status.label ?? '-'} + +
+
+ +
+
+
+
+ +
+
+

Cancha

+

{booking?.courtName ?? '-'}

+

{booking?.sport?.name ?? '-'}

+
+
+
+ +
+ } + label="Cliente" + value={booking?.customerName ?? '-'} + /> + } + label="Teléfono" + value={booking?.customerPhone ?? '-'} + /> + } + label="Fecha" + value={formatDate(booking?.date) || '-'} + /> + } + label="Horario" + value={`${booking?.startTime ?? '--:--'} a ${booking?.endTime ?? '--:--'}`} + /> + } + label="Estado" + value={status.label ?? '-'} + valueClassName="uppercase text-primary" + /> +
+
+ +
+

Acciones

+
+ } + label="Completar" + className="border-primary/45 bg-primary/12 text-primary" + onClick={onComplete} + /> + } + label="Recordar" + className="border-reserved/45 bg-reserved/12 text-reserved" + onClick={onReminder} + /> + } + label="Cancelar" + className="border-destructive/45 bg-destructive/12 text-destructive" + onClick={onCancel} + /> + } + label="No show" + className="border-warning/50 bg-warning/12 text-warning" + onClick={onNoShow} + /> +
+ + + + + +
+
+ ); +} + +function MobileDetailRow({ + icon, + label, + value, + valueClassName, +}: { + icon: React.ReactNode; + label: string; + value: string; + valueClassName?: string; +}) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ); +} + +function MobileActionButton({ + icon, + label, + className, + onClick, +}: { + icon: React.ReactNode; + label: string; + className: string; + onClick: () => void; +}) { + return ( + + ); +} + function DetailItem({ icon, label, @@ -223,7 +427,12 @@ function DetailItem({ className?: string; }) { return ( -
+
{withDivider && (
+
{icon}

{label}

@@ -277,7 +486,7 @@ function ActionButton({