From 8210a4bd9b91fcc910781a63ade0245d539f44c1 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Fri, 10 Apr 2026 16:33:45 -0300 Subject: [PATCH] feat(home): move manual booking to responsive dialog - Add Dice UI responsive-dialog component - Move manual booking form to dialog (drawer on mobile) - Filter past time slots when booking for today - Rename button to 'Nueva Reserva' --- apps/backend/bun.lock | 26 ++ apps/frontend/components.json | 8 +- apps/frontend/package.json | 3 +- apps/frontend/src/components/ui/dialog.tsx | 2 + apps/frontend/src/components/ui/drawer.tsx | 118 ++++++ .../src/components/ui/responsive-dialog.tsx | 254 ++++++++++++ apps/frontend/src/features/home/home-page.tsx | 362 ++++++++++-------- apps/frontend/src/hooks/use-as-ref.ts | 15 + .../src/hooks/use-isomorphic-layout-effect.ts | 6 + apps/frontend/src/hooks/use-lazy-ref.ts | 13 + apps/frontend/src/hooks/use-mobile.ts | 17 + bun.lock | 3 + 12 files changed, 671 insertions(+), 156 deletions(-) create mode 100644 apps/backend/bun.lock create mode 100644 apps/frontend/src/components/ui/drawer.tsx create mode 100644 apps/frontend/src/components/ui/responsive-dialog.tsx create mode 100644 apps/frontend/src/hooks/use-as-ref.ts create mode 100644 apps/frontend/src/hooks/use-isomorphic-layout-effect.ts create mode 100644 apps/frontend/src/hooks/use-lazy-ref.ts create mode 100644 apps/frontend/src/hooks/use-mobile.ts diff --git a/apps/backend/bun.lock b/apps/backend/bun.lock new file mode 100644 index 0000000..3990d98 --- /dev/null +++ b/apps/backend/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "backend", + "dependencies": { + "hono": "^4.12.9", + }, + "devDependencies": { + "@types/bun": "latest", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/apps/frontend/components.json b/apps/frontend/components.json index 5c23ec4..790218c 100644 --- a/apps/frontend/components.json +++ b/apps/frontend/components.json @@ -12,6 +12,8 @@ }, "iconLibrary": "lucide", "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -19,7 +21,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "registries": { + "@diceui": "https://diceui.com/r/{name}.json" + } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 940f2f8..ddc5890 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -37,7 +37,8 @@ "shadcn": "^4.1.2", "simple-icons": "^16.15.0", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2" }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", diff --git a/apps/frontend/src/components/ui/dialog.tsx b/apps/frontend/src/components/ui/dialog.tsx index 7a74ddd..bfb17f0 100644 --- a/apps/frontend/src/components/ui/dialog.tsx +++ b/apps/frontend/src/components/ui/dialog.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Dialog as DialogPrimitive } from 'radix-ui'; import * as React from 'react'; diff --git a/apps/frontend/src/components/ui/drawer.tsx b/apps/frontend/src/components/ui/drawer.tsx new file mode 100644 index 0000000..70ab846 --- /dev/null +++ b/apps/frontend/src/components/ui/drawer.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { Drawer as DrawerPrimitive } from 'vaul'; + +import { cn } from '@/lib/utils'; + +function Drawer({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerClose({ ...props }: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/apps/frontend/src/components/ui/responsive-dialog.tsx b/apps/frontend/src/components/ui/responsive-dialog.tsx new file mode 100644 index 0000000..8759447 --- /dev/null +++ b/apps/frontend/src/components/ui/responsive-dialog.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + DrawerPortal, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/drawer'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { cn } from '@/lib/utils'; +import * as React from 'react'; + +const ROOT_NAME = 'ResponsiveDialog'; + +interface StoreState { + open: boolean; + isMobile: boolean; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + setState: (key: K, value: StoreState[K]) => void; + notify: () => void; +} + +const StoreContext = React.createContext(null); + +function useStore(selector: (state: StoreState) => T, ogStore?: Store | null): T { + const contextStore = React.useContext(StoreContext); + const store = ogStore ?? contextStore; + + if (!store) { + throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``); + } + + const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface ResponsiveDialogProps extends React.ComponentProps { + breakpoint?: number; +} + +function ResponsiveDialog({ + breakpoint = 768, + open: openProp, + defaultOpen = false, + onOpenChange: onOpenChangeProp, + ...props +}: ResponsiveDialogProps) { + const isMobile = useIsMobile(breakpoint); + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => ({ + open: openProp ?? defaultOpen, + isMobile, + })); + + const onOpenChangeRef = useAsRef(onOpenChangeProp); + + 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 === 'open' && typeof value === 'boolean') { + stateRef.current.open = value; + onOpenChangeRef.current?.(value); + } else { + stateRef.current[key] = value; + } + + store.notify(); + }, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + }; + }, [listenersRef, stateRef, onOpenChangeRef]); + + if (stateRef.current.isMobile !== isMobile) { + stateRef.current.isMobile = isMobile; + } + + const open = useStore((state) => state.open, store); + + useIsomorphicLayoutEffect(() => { + if (openProp !== undefined) { + store.setState('open', openProp); + } + }, [openProp]); + + const onOpenChange = React.useCallback( + (value: boolean) => { + store.setState('open', value); + }, + [store] + ); + + if (isMobile) { + return ( + + + + ); + } + + return ( + + + + ); +} + +function ResponsiveDialogTrigger({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogClose({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogPortal({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogOverlay({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogContent({ + className, + ...props +}: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ( + + ); + } + + return ; +} + +function ResponsiveDialogHeader({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogFooter({ + showCloseButton, + ...props +}: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogTitle({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +function ResponsiveDialogDescription({ ...props }: React.ComponentProps) { + const isMobile = useStore((state) => state.isMobile); + + if (isMobile) { + return ; + } + + return ; +} + +export { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogOverlay, + ResponsiveDialogPortal, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +}; diff --git a/apps/frontend/src/features/home/home-page.tsx b/apps/frontend/src/features/home/home-page.tsx index 51f256f..44027bb 100644 --- a/apps/frontend/src/features/home/home-page.tsx +++ b/apps/frontend/src/features/home/home-page.tsx @@ -2,6 +2,16 @@ import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from '@/components/ui/responsive-dialog'; import { Select, SelectContent, @@ -120,6 +130,7 @@ export function HomePage() { const [selectedSportId, setSelectedSportId] = useState(); const [selectedCourtId, setSelectedCourtId] = useState(''); const [selectedStartTime, setSelectedStartTime] = useState(''); + const [isManualBookingOpen, setIsManualBookingOpen] = useState(false); useEffect(() => { setCurrentComplexSlug(getCurrentComplexSlug()); @@ -246,7 +257,7 @@ export function HomePage() { register, handleSubmit, reset, - formState: { errors, isSubmitting, isValid }, + formState: { errors, isValid }, } = useForm({ resolver: zodResolver(manualBookingSchema), mode: 'onChange', @@ -278,6 +289,7 @@ export function HomePage() { reset(); setSelectedCourtId(''); setSelectedStartTime(''); + setIsManualBookingOpen(false); await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] }); await queryClient.invalidateQueries({ @@ -319,10 +331,203 @@ export function HomePage() { return (
-

Panel de reservas

-

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

+
+
+

Panel de reservas

+

+ Gestiona turnos del día y futuros para {selectedComplex.complexName}. +

+
+ { + setIsManualBookingOpen(open); + if (!open) { + reset(); + setManualDate(todayIso); + setSelectedSportId(undefined); + setSelectedCourtId(''); + setSelectedStartTime(''); + } + }} + > + + + + e.preventDefault()} + > + + Reserva manual + + Crea un turno para atención telefónica o mostrador. + + + +
+
+ + Fecha + { + const isoDate = date ? toIsoDateLocal(date) : todayIso; + setManualDate(isoDate); + setSelectedCourtId(''); + setSelectedStartTime(''); + }} + minDate={new Date()} + placeholder="Selecciona una fecha" + /> + + + {manualAvailabilityQuery.data?.sportSelectionRequired && ( + + Deporte + + + )} + + + Cancha + + + + + Horario + + +
+ + {manualAvailabilityQuery.isLoading && ( +

Cargando horarios disponibles...

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

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

+ )} + + + Nombre + + + + + + Teléfono + + + + + {createManualBookingMutation.isError && ( +

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

+ )} +
+ + + + + + + +
+
+
@@ -443,153 +648,6 @@ export function HomePage() {

)}
- -
-

Reserva manual

-

- Crea un turno para atención telefónica o mostrador. -

- -
- - Fecha - { - const isoDate = date ? toIsoDateLocal(date) : todayIso; - setManualDate(isoDate); - setSelectedCourtId(''); - setSelectedStartTime(''); - }} - minDate={new Date()} - placeholder="Selecciona una fecha" - /> - - - {manualAvailabilityQuery.data?.sportSelectionRequired && ( - - Deporte - - - )} - - - Cancha - - -
- -
- - Horario - - -
- - {manualAvailabilityQuery.isLoading && ( -

Cargando horarios disponibles...

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

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

- )} - -
- - Nombre - - - - - - Teléfono - - - - - {createManualBookingMutation.isError && ( -

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

- )} - - -
-
); } diff --git a/apps/frontend/src/hooks/use-as-ref.ts b/apps/frontend/src/hooks/use-as-ref.ts new file mode 100644 index 0000000..9c8d508 --- /dev/null +++ b/apps/frontend/src/hooks/use-as-ref.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; + +function useAsRef(props: T) { + const ref = React.useRef(props); + + useIsomorphicLayoutEffect(() => { + ref.current = props; + }); + + return ref; +} + +export { useAsRef }; diff --git a/apps/frontend/src/hooks/use-isomorphic-layout-effect.ts b/apps/frontend/src/hooks/use-isomorphic-layout-effect.ts new file mode 100644 index 0000000..040619d --- /dev/null +++ b/apps/frontend/src/hooks/use-isomorphic-layout-effect.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; + +export { useIsomorphicLayoutEffect }; diff --git a/apps/frontend/src/hooks/use-lazy-ref.ts b/apps/frontend/src/hooks/use-lazy-ref.ts new file mode 100644 index 0000000..65c384f --- /dev/null +++ b/apps/frontend/src/hooks/use-lazy-ref.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; + +function useLazyRef(fn: () => T) { + const ref = React.useRef(null); + + if (ref.current === null) { + ref.current = fn(); + } + + return ref as React.RefObject; +} + +export { useLazyRef }; diff --git a/apps/frontend/src/hooks/use-mobile.ts b/apps/frontend/src/hooks/use-mobile.ts new file mode 100644 index 0000000..d946b1b --- /dev/null +++ b/apps/frontend/src/hooks/use-mobile.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; + +export function useIsMobile(mobileBreakpoint = 768) { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${mobileBreakpoint - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < mobileBreakpoint); + }; + mql.addEventListener('change', onChange); + setIsMobile(window.innerWidth < mobileBreakpoint); + return () => mql.removeEventListener('change', onChange); + }, [mobileBreakpoint]); + + return !!isMobile; +} diff --git a/bun.lock b/bun.lock index db63d4a..6e763be 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "simple-icons": "^16.15.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", @@ -1410,6 +1411,8 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],