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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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.'
- )}
-
- )}
-
-
-
);
}
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=="],