feat: add city/state/country to complex, new settings page with sidebar, and Biome linting
- Added city, state, and country optional fields to Complex model - Updated onboarding to include optional location fields - Created new settings page with sidebar navigation (Datos del Complejo, Canchas) - Replaced ESLint with Biome for frontend and backend linting - Added parallel dev script with concurrently - Migrated register-routes to use direct app.route() pattern
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -38,20 +40,14 @@
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/router-plugin": "^1.167.12",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
@@ -51,8 +50,7 @@
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +165,7 @@
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
import { Avatar as AvatarPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
className={cn('aspect-square size-full rounded-full object-cover', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
@@ -47,64 +41,54 @@ function AvatarFallback({
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
|
||||
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
|
||||
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
|
||||
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
function AvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-8',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
'icon-sm':
|
||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
const Comp = asChild ? Slot.Root : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -61,7 +61,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,54 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: "flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex items-center justify-center pt-1 relative",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
months: 'flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
caption: 'flex items-center justify-center pt-1 relative',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
|
||||
),
|
||||
nav_button_previous: "absolute left-0 top-0",
|
||||
nav_button_next: "absolute right-0 top-0",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
nav_button_previous: 'absolute left-0 top-0',
|
||||
nav_button_next: 'absolute right-0 top-0',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
head_row: 'flex',
|
||||
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
row: 'flex w-full mt-2',
|
||||
cell: 'text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_range_end: 'day-range-end',
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
day_today: 'bg-accent text-accent-foreground',
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
day_disabled: 'text-muted-foreground opacity-50',
|
||||
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
@@ -57,8 +50,8 @@ function Calendar({
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
Calendar.displayName = 'Calendar';
|
||||
|
||||
export { Calendar }
|
||||
export { Calendar };
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { format } from "date-fns"
|
||||
import { es } from "date-fns/locale"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DatePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
disabled?: (date: Date) => boolean
|
||||
className?: string
|
||||
placeholder?: string
|
||||
minDate?: Date
|
||||
value?: Date;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
disabled?: (date: Date) => boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
minDate?: Date;
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
@@ -28,19 +24,19 @@ export function DatePicker({
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
placeholder = "Selecciona una fecha",
|
||||
placeholder = 'Selecciona una fecha',
|
||||
minDate,
|
||||
}: DatePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const isDateDisabled = React.useCallback(
|
||||
(date: Date) => {
|
||||
if (minDate && date < minDate) return true
|
||||
if (disabled) return disabled(date)
|
||||
return false
|
||||
if (minDate && date < minDate) return true;
|
||||
if (disabled) return disabled(date);
|
||||
return false;
|
||||
},
|
||||
[minDate, disabled]
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -48,18 +44,14 @@ export function DatePicker({
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
'w-full justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
data-empty={!value}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? (
|
||||
format(value, "PPP", { locale: es })
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
{value ? format(value, 'PPP', { locale: es }) : <span>{placeholder}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
@@ -67,13 +59,13 @@ export function DatePicker({
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(date) => {
|
||||
onChange?.(date)
|
||||
setOpen(false)
|
||||
onChange?.(date);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={isDateDisabled}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -37,12 +29,12 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
'fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -51,7 +43,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -59,7 +51,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,30 +59,21 @@ function DialogContent({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<div data-slot="dialog-header" className={cn('flex flex-col gap-2', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -98,14 +81,14 @@ function DialogFooter({
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -117,23 +100,17 @@ function DialogFooter({
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
className={cn('font-heading text-base leading-none font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -144,12 +121,12 @@ function DialogDescription({
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -163,4 +140,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
align = 'start',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
@@ -43,29 +34,28 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -78,7 +68,7 @@ function DropdownMenuItem({
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -88,7 +78,7 @@ function DropdownMenuCheckboxItem({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
@@ -106,24 +96,18 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -132,7 +116,7 @@ function DropdownMenuRadioItem({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
@@ -149,13 +133,12 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -163,19 +146,19 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -185,32 +168,27 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -219,7 +197,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -234,7 +212,7 @@ function DropdownMenuSubTrigger({
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -244,10 +222,13 @@ function DropdownMenuSubContent({
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
'z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -266,4 +247,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,77 +1,74 @@
|
||||
import { useMemo } from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
variant = 'legend',
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
'mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
|
||||
horizontal:
|
||||
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
responsive:
|
||||
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
});
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
@@ -80,80 +77,74 @@ function Field({
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
className={cn('group/field-content flex flex-1 flex-col gap-0.5 leading-snug', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
|
||||
'last:mt-0 nth-last-2:-mt-1',
|
||||
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}: React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -168,7 +159,7 @@ function FieldSeparator({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
@@ -176,50 +167,45 @@ function FieldError({
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
if (uniqueErrors?.length === 1) {
|
||||
return uniqueErrors[0]?.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
className={cn('text-sm font-normal text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -233,4 +219,4 @@ export {
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import type {
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
} from "react-hook-form"
|
||||
import { Controller, FormProvider, useFormContext } from "react-hook-form"
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import * as React from 'react';
|
||||
import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
|
||||
import { Controller, FormProvider, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Form = FormProvider
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
@@ -34,21 +28,21 @@ const FormField = <
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -57,110 +51,103 @@ const useFormField = () => {
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
@@ -171,4 +158,4 @@ export {
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormMessage,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MinusIcon } from 'lucide-react';
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"cn-input-otp flex items-center has-disabled:opacity-50",
|
||||
'cn-input-otp flex items-center has-disabled:opacity-50',
|
||||
containerClassName
|
||||
)}
|
||||
spellCheck={false}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(
|
||||
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||
'flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}: React.ComponentProps<'div'> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||
'relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -65,10 +65,10 @@ function InputOTPSlot({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
@@ -76,10 +76,9 @@ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<MinusIcon
|
||||
/>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import { Label as LabelPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
import { Select as SelectPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
function SelectGroup({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
className={cn('scroll-my-1 p-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -52,22 +45,27 @@ function SelectTrigger({
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
position = 'item-aligned',
|
||||
align = 'center',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
data-align-trigger={position === 'item-aligned'}
|
||||
className={cn(
|
||||
'relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
@@ -76,8 +74,8 @@ function SelectContent({
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
'data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)',
|
||||
position === 'popper' && ''
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -85,20 +83,17 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
className={cn('px-1.5 py-1 text-xs text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
@@ -122,7 +117,7 @@ function SelectItem({
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
@@ -132,10 +127,10 @@ function SelectSeparator({
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -151,10 +146,9 @@ function SelectScrollUpButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -170,10 +164,9 @@ function SelectScrollDownButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -187,4 +180,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
@@ -17,12 +17,12 @@ function Separator({
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -8,5 +8,5 @@ export function AboutPage() {
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { type Court, type CreateCourtInput } from '@repo/api-contract'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex'
|
||||
import { CourtFormSection } from './components/court-form-section'
|
||||
import { CourtListSection } from './components/court-list-section'
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { type Court, type CreateCourtInput } from '@repo/api-contract';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CourtFormSection } from './components/court-form-section';
|
||||
import { CourtListSection } from './components/court-list-section';
|
||||
|
||||
type ComplexCourtsPageProps = {
|
||||
complexSlug: string
|
||||
}
|
||||
complexSlug: string;
|
||||
};
|
||||
|
||||
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
|
||||
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(complexSlug)
|
||||
}, [complexSlug])
|
||||
setCurrentComplexSlug(complexSlug);
|
||||
}, [complexSlug]);
|
||||
|
||||
const complexQuery = useQuery({
|
||||
queryKey: ['complex-by-slug', complexSlug],
|
||||
queryFn: () => apiClient.complexes.getBySlug(complexSlug),
|
||||
})
|
||||
});
|
||||
|
||||
const complexId = complexQuery.data?.id ?? null
|
||||
const complexId = complexQuery.data?.id ?? null;
|
||||
|
||||
const courtsQuery = useQuery({
|
||||
queryKey: ['courts', complexId],
|
||||
enabled: Boolean(complexId),
|
||||
queryFn: () => apiClient.courts.listByComplex(complexId as string),
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl space-y-6 px-4 py-4 sm:px-6">
|
||||
@@ -47,7 +47,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
editingCourt={editingCourt}
|
||||
initialDraft={initialDraft}
|
||||
onCancelEdit={() => {
|
||||
setEditingCourt(null)
|
||||
setEditingCourt(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
isLoading={courtsQuery.isLoading}
|
||||
isError={courtsQuery.isError}
|
||||
onDuplicateCourt={(court) => {
|
||||
setEditingCourt(null)
|
||||
setEditingCourt(null);
|
||||
setInitialDraft({
|
||||
name: `${court.name} - Copy`,
|
||||
sportId: court.sportId,
|
||||
@@ -67,15 +67,15 @@ export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
})),
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
onEditCourt={(court) => {
|
||||
setInitialDraft(null)
|
||||
setEditingCourt(court)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
setInitialDraft(null);
|
||||
setEditingCourt(court);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Building2, MapPin, Settings } from 'lucide-react'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex'
|
||||
import { ComplexDetailsSection } from './components/complex-details-section'
|
||||
import { ComplexCourtsSection } from './components/complex-courts-section'
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Building2, MapPin, Settings } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ComplexCourtsSection } from './components/complex-courts-section';
|
||||
import { ComplexDetailsSection } from './components/complex-details-section';
|
||||
|
||||
type ComplexSettingsPageProps = {
|
||||
complexSlug: string
|
||||
}
|
||||
complexSlug: string;
|
||||
};
|
||||
|
||||
type TabOption = 'details' | 'courts'
|
||||
type TabOption = 'details' | 'courts';
|
||||
|
||||
export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabOption>('details')
|
||||
const [activeTab, setActiveTab] = useState<TabOption>('details');
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(complexSlug)
|
||||
}, [complexSlug])
|
||||
setCurrentComplexSlug(complexSlug);
|
||||
}, [complexSlug]);
|
||||
|
||||
const complexQuery = useQuery({
|
||||
queryKey: ['complex-by-slug', complexSlug],
|
||||
queryFn: () => apiClient.complexes.getBySlug(complexSlug),
|
||||
})
|
||||
});
|
||||
|
||||
const complexId = complexQuery.data?.id ?? null
|
||||
const complexId = complexQuery.data?.id ?? null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'details' as const, label: 'Datos del complejo', icon: Building2 },
|
||||
{ id: 'courts' as const, label: 'Canchas', icon: MapPin },
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-4 sm:px-6">
|
||||
@@ -46,8 +46,8 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[200px_1fr]">
|
||||
<nav className="flex flex-row gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const isActive = activeTab === tab.id
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -62,7 +62,7 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
<Icon className="size-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -75,13 +75,9 @@ export function ComplexSettingsPage({ complexSlug }: ComplexSettingsPageProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'courts' && (
|
||||
<ComplexCourtsSection
|
||||
complexId={complexId}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'courts' && <ComplexCourtsSection complexId={complexId} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { type Court, type CreateCourtInput } from '@repo/api-contract'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { CourtFormSection } from './court-form-section'
|
||||
import { CourtListSection } from './court-list-section'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { type Court, type CreateCourtInput } from '@repo/api-contract';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { CourtFormSection } from './court-form-section';
|
||||
import { CourtListSection } from './court-list-section';
|
||||
|
||||
type ComplexCourtsSectionProps = {
|
||||
complexId: string | null
|
||||
}
|
||||
complexId: string | null;
|
||||
};
|
||||
|
||||
export function ComplexCourtsSection({
|
||||
complexId,
|
||||
}: ComplexCourtsSectionProps) {
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
|
||||
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null)
|
||||
export function ComplexCourtsSection({ complexId }: ComplexCourtsSectionProps) {
|
||||
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||
const [initialDraft, setInitialDraft] = useState<CreateCourtInput | null>(null);
|
||||
|
||||
const courtsQuery = useQuery({
|
||||
queryKey: ['courts', complexId],
|
||||
enabled: Boolean(complexId),
|
||||
queryFn: () => apiClient.courts.listByComplex(complexId as string),
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mt-6 rounded-xl border bg-card p-5">
|
||||
@@ -30,16 +28,14 @@ export function ComplexCourtsSection({
|
||||
<h3 className="text-lg font-medium">Canchas</h3>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Alta y edición de canchas del complejo.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">Alta y edición de canchas del complejo.</p>
|
||||
|
||||
<CourtFormSection
|
||||
complexId={complexId}
|
||||
editingCourt={editingCourt}
|
||||
initialDraft={initialDraft}
|
||||
onCancelEdit={() => {
|
||||
setEditingCourt(null)
|
||||
setEditingCourt(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -48,7 +44,7 @@ export function ComplexCourtsSection({
|
||||
isLoading={courtsQuery.isLoading}
|
||||
isError={courtsQuery.isError}
|
||||
onDuplicateCourt={(court) => {
|
||||
setEditingCourt(null)
|
||||
setEditingCourt(null);
|
||||
setInitialDraft({
|
||||
name: `${court.name} - Copy`,
|
||||
sportId: court.sportId,
|
||||
@@ -59,15 +55,15 @@ export function ComplexCourtsSection({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
})),
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
onEditCourt={(court) => {
|
||||
setInitialDraft(null)
|
||||
setEditingCourt(court)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
setInitialDraft(null);
|
||||
setEditingCourt(court);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { Complex, UpdateComplexInput } from '@repo/api-contract'
|
||||
import { updateComplexSchema } from '@repo/api-contract'
|
||||
import { Building2, Loader2 } from 'lucide-react'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { Complex, UpdateComplexInput } from '@repo/api-contract';
|
||||
import { updateComplexSchema } from '@repo/api-contract';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Building2, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type ComplexDetailsSectionProps = {
|
||||
complex: Complex | null
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}
|
||||
complex: Complex | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
complexName: string
|
||||
physicalAddress: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
}
|
||||
complexName: string;
|
||||
physicalAddress: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export function ComplexDetailsSection({
|
||||
complex,
|
||||
isLoading,
|
||||
isError,
|
||||
}: ComplexDetailsSectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
export function ComplexDetailsSection({ complex, isLoading, isError }: ComplexDetailsSectionProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formValues, setFormValues] = useState<FormValues>({
|
||||
complexName: '',
|
||||
physicalAddress: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
})
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
});
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (complex) {
|
||||
@@ -47,28 +43,27 @@ export function ComplexDetailsSection({
|
||||
city: complex.city ?? '',
|
||||
state: complex.state ?? '',
|
||||
country: complex.country ?? '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [complex])
|
||||
}, [complex]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateComplexInput) =>
|
||||
apiClient.complexes.update(complex!.id, data),
|
||||
mutationFn: (data: UpdateComplexInput) => apiClient.complexes.update(complex!.id, data),
|
||||
onSuccess: (updatedComplex) => {
|
||||
setSuccessMessage('Datos actualizados correctamente.')
|
||||
setErrorMessage(null)
|
||||
queryClient.setQueryData(['complex-by-slug', complex?.complexSlug], updatedComplex)
|
||||
setSuccessMessage('Datos actualizados correctamente.');
|
||||
setErrorMessage(null);
|
||||
queryClient.setQueryData(['complex-by-slug', complex?.complexSlug], updatedComplex);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || 'Error al actualizar los datos.')
|
||||
setSuccessMessage(null)
|
||||
setErrorMessage(error.message || 'Error al actualizar los datos.');
|
||||
setSuccessMessage(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
e.preventDefault();
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const result = updateComplexSchema.safeParse({
|
||||
complexName: formValues.complexName,
|
||||
@@ -76,15 +71,15 @@ export function ComplexDetailsSection({
|
||||
city: formValues.city || null,
|
||||
state: formValues.state || null,
|
||||
country: formValues.country || null,
|
||||
})
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
setErrorMessage(result.error.issues[0]?.message || 'Datos inválidos.')
|
||||
return
|
||||
setErrorMessage(result.error.issues[0]?.message || 'Datos inválidos.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate(result.data)
|
||||
}
|
||||
updateMutation.mutate(result.data);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -96,7 +91,7 @@ export function ComplexDetailsSection({
|
||||
<Separator className="my-4" />
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !complex) {
|
||||
@@ -107,11 +102,9 @@ export function ComplexDetailsSection({
|
||||
<h3 className="text-lg font-medium">Datos del complejo</h3>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<p className="text-sm text-destructive">
|
||||
No se pudieron cargar los datos del complejo.
|
||||
</p>
|
||||
<p className="text-sm text-destructive">No se pudieron cargar los datos del complejo.</p>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -129,9 +122,7 @@ export function ComplexDetailsSection({
|
||||
id="complex-name"
|
||||
type="text"
|
||||
value={formValues.complexName}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, complexName: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setFormValues((prev) => ({ ...prev, complexName: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -156,9 +147,7 @@ export function ComplexDetailsSection({
|
||||
type="text"
|
||||
placeholder="Ej: Salta"
|
||||
value={formValues.city}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, city: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setFormValues((prev) => ({ ...prev, city: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -169,9 +158,7 @@ export function ComplexDetailsSection({
|
||||
type="text"
|
||||
placeholder="Ej: Salta"
|
||||
value={formValues.state}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, state: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setFormValues((prev) => ({ ...prev, state: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -182,32 +169,23 @@ export function ComplexDetailsSection({
|
||||
type="text"
|
||||
placeholder="Ej: Argentina"
|
||||
value={formValues.country}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, country: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setFormValues((prev) => ({ ...prev, country: e.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
|
||||
{successMessage && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{successMessage}
|
||||
</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400">{successMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createCourtSchema, type Court, type CreateCourtInput } from '@repo/api-contract'
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,26 +6,31 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client'
|
||||
import { DAY_OPTIONS } from '../lib/court.constants'
|
||||
import { createDefaultCourtValues } from '../lib/court.utils'
|
||||
} from '@/components/ui/select';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { type Court, type CreateCourtInput, createCourtSchema } from '@repo/api-contract';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { DAY_OPTIONS } from '../lib/court.constants';
|
||||
import { createDefaultCourtValues } from '../lib/court.utils';
|
||||
|
||||
type CourtFormSectionProps = {
|
||||
complexId: string | null
|
||||
editingCourt: Court | null
|
||||
initialDraft: CreateCourtInput | null
|
||||
onCancelEdit: () => void
|
||||
}
|
||||
complexId: string | null;
|
||||
editingCourt: Court | null;
|
||||
initialDraft: CreateCourtInput | null;
|
||||
onCancelEdit: () => void;
|
||||
};
|
||||
|
||||
export function CourtFormSection({
|
||||
complexId,
|
||||
@@ -38,67 +38,67 @@ export function CourtFormSection({
|
||||
initialDraft,
|
||||
onCancelEdit,
|
||||
}: CourtFormSectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [isWeekModalOpen, setIsWeekModalOpen] = useState(false)
|
||||
const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00')
|
||||
const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00')
|
||||
const [weekModalError, setWeekModalError] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient();
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [isWeekModalOpen, setIsWeekModalOpen] = useState(false);
|
||||
const [weekDefaultStartTime, setWeekDefaultStartTime] = useState('08:00');
|
||||
const [weekDefaultEndTime, setWeekDefaultEndTime] = useState('22:00');
|
||||
const [weekModalError, setWeekModalError] = useState<string | null>(null);
|
||||
|
||||
const sportsQuery = useQuery({
|
||||
queryKey: ['sports'],
|
||||
queryFn: () => apiClient.sports.list(),
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm<CreateCourtInput>({
|
||||
resolver: zodResolver(createCourtSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: createDefaultCourtValues(),
|
||||
})
|
||||
});
|
||||
|
||||
const availabilityFieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'availability',
|
||||
})
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (payload: CreateCourtInput) => {
|
||||
if (editingCourt) {
|
||||
return apiClient.courts.update(editingCourt.id, payload)
|
||||
return apiClient.courts.update(editingCourt.id, payload);
|
||||
}
|
||||
if (!complexId) {
|
||||
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.')
|
||||
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.');
|
||||
}
|
||||
return apiClient.courts.create(complexId, payload)
|
||||
return apiClient.courts.create(complexId, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['courts', complexId],
|
||||
})
|
||||
setFormError(null)
|
||||
});
|
||||
setFormError(null);
|
||||
if (editingCourt) {
|
||||
onCancelEdit()
|
||||
onCancelEdit();
|
||||
}
|
||||
form.reset(createDefaultCourtValues())
|
||||
form.reset(createDefaultCourtValues());
|
||||
},
|
||||
onError: (error) => {
|
||||
const message =
|
||||
error instanceof ApiClientError
|
||||
? error.message
|
||||
: 'No se pudo guardar la cancha. Intenta nuevamente.'
|
||||
setFormError(message)
|
||||
: 'No se pudo guardar la cancha. Intenta nuevamente.';
|
||||
setFormError(message);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const activeSports = useMemo(
|
||||
() => (sportsQuery.data ?? []).filter((sport) => sport.isActive),
|
||||
[sportsQuery.data],
|
||||
)
|
||||
[sportsQuery.data]
|
||||
);
|
||||
|
||||
const onSubmit = async (values: CreateCourtInput) => {
|
||||
setFormError(null)
|
||||
await saveMutation.mutateAsync(values)
|
||||
}
|
||||
setFormError(null);
|
||||
await saveMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCourt) {
|
||||
@@ -112,33 +112,33 @@ export function CourtFormSection({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
})),
|
||||
})
|
||||
return
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialDraft) {
|
||||
form.reset(initialDraft)
|
||||
return
|
||||
form.reset(initialDraft);
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(createDefaultCourtValues())
|
||||
}, [editingCourt, form, initialDraft])
|
||||
form.reset(createDefaultCourtValues());
|
||||
}, [editingCourt, form, initialDraft]);
|
||||
|
||||
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha'
|
||||
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha'
|
||||
const title = editingCourt ? 'Editar cancha' : 'Nueva cancha';
|
||||
const buttonText = editingCourt ? 'Guardar cambios' : 'Agregar cancha';
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5 mb-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Configura nombre, deporte, duración, precio base y horarios. El backend ya
|
||||
contempla precios por franja horaria para la siguiente etapa.
|
||||
Configura nombre, deporte, duración, precio base y horarios. El backend ya contempla precios
|
||||
por franja horaria para la siguiente etapa.
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
void onSubmit(values)
|
||||
void onSubmit(values);
|
||||
})}
|
||||
>
|
||||
<Field data-invalid={Boolean(form.formState.errors.name)}>
|
||||
@@ -211,9 +211,7 @@ export function CourtFormSection({
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">Rangos horarios</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define uno o más rangos por día.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Define uno o más rangos por día.</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<Button
|
||||
@@ -221,11 +219,11 @@ export function CourtFormSection({
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
const availability = form.getValues('availability')
|
||||
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00')
|
||||
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00')
|
||||
setWeekModalError(null)
|
||||
setIsWeekModalOpen(true)
|
||||
const availability = form.getValues('availability');
|
||||
setWeekDefaultStartTime(availability[0]?.startTime ?? '08:00');
|
||||
setWeekDefaultEndTime(availability[0]?.endTime ?? '22:00');
|
||||
setWeekModalError(null);
|
||||
setIsWeekModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Agregar semana completa
|
||||
@@ -239,7 +237,7 @@ export function CourtFormSection({
|
||||
dayOfWeek: 'MONDAY',
|
||||
startTime: '08:00',
|
||||
endTime: '22:00',
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
Agregar rango
|
||||
@@ -307,7 +305,7 @@ export function CourtFormSection({
|
||||
className="w-full md:w-auto"
|
||||
disabled={availabilityFieldArray.fields.length === 1}
|
||||
onClick={() => {
|
||||
availabilityFieldArray.remove(index)
|
||||
availabilityFieldArray.remove(index);
|
||||
}}
|
||||
>
|
||||
Quitar
|
||||
@@ -338,9 +336,9 @@ export function CourtFormSection({
|
||||
<Dialog
|
||||
open={isWeekModalOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setIsWeekModalOpen(nextOpen)
|
||||
setIsWeekModalOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setWeekModalError(null)
|
||||
setWeekModalError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -360,8 +358,8 @@ export function CourtFormSection({
|
||||
type="time"
|
||||
value={weekDefaultStartTime}
|
||||
onChange={(event) => {
|
||||
setWeekDefaultStartTime(event.target.value)
|
||||
setWeekModalError(null)
|
||||
setWeekDefaultStartTime(event.target.value);
|
||||
setWeekModalError(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@@ -373,8 +371,8 @@ export function CourtFormSection({
|
||||
type="time"
|
||||
value={weekDefaultEndTime}
|
||||
onChange={(event) => {
|
||||
setWeekDefaultEndTime(event.target.value)
|
||||
setWeekModalError(null)
|
||||
setWeekDefaultEndTime(event.target.value);
|
||||
setWeekModalError(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@@ -387,8 +385,8 @@ export function CourtFormSection({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsWeekModalOpen(false)
|
||||
setWeekModalError(null)
|
||||
setIsWeekModalOpen(false);
|
||||
setWeekModalError(null);
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
@@ -397,29 +395,27 @@ export function CourtFormSection({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (weekDefaultStartTime >= weekDefaultEndTime) {
|
||||
setWeekModalError(
|
||||
'El horario de inicio debe ser menor al horario de fin.',
|
||||
)
|
||||
return
|
||||
setWeekModalError('El horario de inicio debe ser menor al horario de fin.');
|
||||
return;
|
||||
}
|
||||
|
||||
const availability = form.getValues('availability')
|
||||
const existingDays = new Set(availability.map((item) => item.dayOfWeek))
|
||||
const availability = form.getValues('availability');
|
||||
const existingDays = new Set(availability.map((item) => item.dayOfWeek));
|
||||
|
||||
const missingDays = DAY_OPTIONS.filter(
|
||||
(option) => !existingDays.has(option.value),
|
||||
(option) => !existingDays.has(option.value)
|
||||
).map((option) => ({
|
||||
dayOfWeek: option.value,
|
||||
startTime: weekDefaultStartTime,
|
||||
endTime: weekDefaultEndTime,
|
||||
}))
|
||||
}));
|
||||
|
||||
if (missingDays.length > 0) {
|
||||
availabilityFieldArray.append(missingDays)
|
||||
availabilityFieldArray.append(missingDays);
|
||||
}
|
||||
|
||||
setIsWeekModalOpen(false)
|
||||
setWeekModalError(null)
|
||||
setIsWeekModalOpen(false);
|
||||
setWeekModalError(null);
|
||||
}}
|
||||
>
|
||||
Agregar días faltantes
|
||||
@@ -428,5 +424,5 @@ export function CourtFormSection({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Court } from '@repo/api-contract'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatCurrency, summarizeAvailability } from '../lib/court.utils'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Court } from '@repo/api-contract';
|
||||
import { formatCurrency, summarizeAvailability } from '../lib/court.utils';
|
||||
|
||||
type CourtListSectionProps = {
|
||||
courts: Court[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
onDuplicateCourt: (court: Court) => void
|
||||
onEditCourt: (court: Court) => void
|
||||
}
|
||||
courts: Court[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
onDuplicateCourt: (court: Court) => void;
|
||||
onEditCourt: (court: Court) => void;
|
||||
};
|
||||
|
||||
export function CourtListSection({
|
||||
courts,
|
||||
@@ -52,7 +52,7 @@ export function CourtListSection({
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
onDuplicateCourt(court)
|
||||
onDuplicateCourt(court);
|
||||
}}
|
||||
>
|
||||
Duplicar cancha
|
||||
@@ -62,7 +62,7 @@ export function CourtListSection({
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
onEditCourt(court)
|
||||
onEditCourt(court);
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
@@ -84,7 +84,7 @@ export function CourtListSection({
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onDuplicateCourt(court)
|
||||
onDuplicateCourt(court);
|
||||
}}
|
||||
>
|
||||
Duplicar cancha
|
||||
@@ -94,7 +94,7 @@ export function CourtListSection({
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onEditCourt(court)
|
||||
onEditCourt(court);
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
@@ -104,5 +104,5 @@ export function CourtListSection({
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DayOfWeek } from '@repo/api-contract'
|
||||
import type { DayOfWeek } from '@repo/api-contract';
|
||||
|
||||
export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
|
||||
{ value: 'MONDAY', label: 'Lunes' },
|
||||
@@ -8,10 +8,10 @@ export const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
|
||||
{ value: 'FRIDAY', label: 'Viernes' },
|
||||
{ value: 'SATURDAY', label: 'Sábado' },
|
||||
{ value: 'SUNDAY', label: 'Domingo' },
|
||||
]
|
||||
];
|
||||
|
||||
export const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value)
|
||||
export const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value);
|
||||
|
||||
export const DAY_LABEL_BY_VALUE = new Map(
|
||||
DAY_OPTIONS.map((option) => [option.value, option.label]),
|
||||
)
|
||||
DAY_OPTIONS.map((option) => [option.value, option.label])
|
||||
);
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract'
|
||||
import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants'
|
||||
import type { CreateCourtInput, DayOfWeek } from '@repo/api-contract';
|
||||
import { DAY_LABEL_BY_VALUE, DAY_ORDER } from './court.constants';
|
||||
|
||||
function formatTimeCompact(value: string): string {
|
||||
const [rawHours = '00', rawMinutes = '00'] = value.split(':')
|
||||
const hours = Number(rawHours)
|
||||
return `${hours}:${rawMinutes}`
|
||||
const [rawHours = '00', rawMinutes = '00'] = value.split(':');
|
||||
const hours = Number(rawHours);
|
||||
return `${hours}:${rawMinutes}`;
|
||||
}
|
||||
|
||||
function formatDayRange(dayIndexes: number[]): string {
|
||||
if (dayIndexes.length === 0) return ''
|
||||
if (dayIndexes.length === 0) return '';
|
||||
|
||||
const sorted = [...dayIndexes].sort((a, b) => a - b)
|
||||
const chunks: Array<{ start: number; end: number }> = []
|
||||
let start = sorted[0]
|
||||
let previous = sorted[0]
|
||||
const sorted = [...dayIndexes].sort((a, b) => a - b);
|
||||
const chunks: Array<{ start: number; end: number }> = [];
|
||||
let start = sorted[0];
|
||||
let previous = sorted[0];
|
||||
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
const current = sorted[index]
|
||||
const current = sorted[index];
|
||||
|
||||
if (current === previous + 1) {
|
||||
previous = current
|
||||
continue
|
||||
previous = current;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push({ start, end: previous })
|
||||
start = current
|
||||
previous = current
|
||||
chunks.push({ start, end: previous });
|
||||
start = current;
|
||||
previous = current;
|
||||
}
|
||||
|
||||
chunks.push({ start, end: previous })
|
||||
chunks.push({ start, end: previous });
|
||||
|
||||
return chunks
|
||||
.map((chunk) => {
|
||||
const startDay = DAY_ORDER[chunk.start]
|
||||
const endDay = DAY_ORDER[chunk.end]
|
||||
const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined
|
||||
const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined
|
||||
const startDay = DAY_ORDER[chunk.start];
|
||||
const endDay = DAY_ORDER[chunk.end];
|
||||
const startLabel = startDay ? DAY_LABEL_BY_VALUE.get(startDay) : undefined;
|
||||
const endLabel = endDay ? DAY_LABEL_BY_VALUE.get(endDay) : undefined;
|
||||
|
||||
if (!startLabel || !endLabel) return ''
|
||||
if (chunk.start === chunk.end) return startLabel
|
||||
return `${startLabel} a ${endLabel}`
|
||||
if (!startLabel || !endLabel) return '';
|
||||
if (chunk.start === chunk.end) return startLabel;
|
||||
return `${startLabel} a ${endLabel}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function summarizeAvailability(
|
||||
availability: Array<{
|
||||
dayOfWeek: DayOfWeek
|
||||
startTime: string
|
||||
endTime: string
|
||||
}>,
|
||||
dayOfWeek: DayOfWeek;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}>
|
||||
) {
|
||||
const grouped = new Map<string, number[]>()
|
||||
const grouped = new Map<string, number[]>();
|
||||
|
||||
for (const slot of availability) {
|
||||
const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek)
|
||||
if (dayIndex < 0) continue
|
||||
const dayIndex = DAY_ORDER.indexOf(slot.dayOfWeek);
|
||||
if (dayIndex < 0) continue;
|
||||
|
||||
const key = `${slot.startTime}-${slot.endTime}`
|
||||
const current = grouped.get(key) ?? []
|
||||
current.push(dayIndex)
|
||||
grouped.set(key, current)
|
||||
const key = `${slot.startTime}-${slot.endTime}`;
|
||||
const current = grouped.get(key) ?? [];
|
||||
current.push(dayIndex);
|
||||
grouped.set(key, current);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([timeRange, dayIndexes]) => {
|
||||
const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-')
|
||||
const dayLabel = formatDayRange(dayIndexes)
|
||||
return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`
|
||||
const [startTime = '00:00', endTime = '00:00'] = timeRange.split('-');
|
||||
const dayLabel = formatDayRange(dayIndexes);
|
||||
return `${dayLabel} · ${formatTimeCompact(startTime)} a ${formatTimeCompact(endTime)}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
@@ -78,7 +78,7 @@ export function formatCurrency(amount: number): string {
|
||||
style: 'currency',
|
||||
currency: 'ARS',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function createDefaultCourtValues(): CreateCourtInput {
|
||||
@@ -94,5 +94,5 @@ export function createDefaultCourtValues(): CreateCourtInput {
|
||||
endTime: '22:00',
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,153 +1,155 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import type { AdminBooking } from '@repo/api-contract'
|
||||
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 { 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client'
|
||||
} from '@/components/ui/select';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import {
|
||||
getCurrentComplexSlug,
|
||||
setCurrentComplexSlug as persistCurrentComplexSlug,
|
||||
} from '@/lib/current-complex'
|
||||
} from '@/lib/current-complex';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { AdminBooking } from '@repo/api-contract';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const manualBookingSchema = z.object({
|
||||
customerName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, 'Ingresa un nombre valido.')
|
||||
.min(2, 'Ingresa un nombre válido.')
|
||||
.max(120, 'El nombre no puede superar los 120 caracteres.'),
|
||||
customerPhone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(6, 'Ingresa un telefono valido.')
|
||||
.min(6, 'Ingresa un telefono válido.')
|
||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||
})
|
||||
});
|
||||
|
||||
type ManualBookingForm = z.infer<typeof manualBookingSchema>
|
||||
type ManualBookingForm = z.infer<typeof manualBookingSchema>;
|
||||
|
||||
function toIsoDateLocal(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function fromIsoDateLocal(dateIso: string): Date | undefined {
|
||||
const [year, month, day] = dateIso.split('-').map(Number)
|
||||
if (!year || !month || !day) return undefined
|
||||
return new Date(year, month - 1, day)
|
||||
const [year, month, day] = dateIso.split('-').map(Number);
|
||||
if (!year || !month || !day) return undefined;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function extractMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof ApiClientError) {
|
||||
return error.message || fallback
|
||||
return error.message || fallback;
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatDateLabel(dateIso: string) {
|
||||
const [year, month, day] = dateIso.split('-').map(Number)
|
||||
const [year, month, day] = dateIso.split('-').map(Number);
|
||||
|
||||
if (!year || !month || !day) return dateIso
|
||||
if (!year || !month || !day) return dateIso;
|
||||
|
||||
const date = new Date(year, month - 1, day)
|
||||
const date = new Date(year, month - 1, day);
|
||||
return new Intl.DateTimeFormat('es-AR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
}).format(date)
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getEffectiveStatus(booking: AdminBooking): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' {
|
||||
function getEffectiveStatus(
|
||||
booking: AdminBooking
|
||||
): 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW' {
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
return booking.status
|
||||
return booking.status;
|
||||
}
|
||||
|
||||
// Check if booking is past end time and still confirmed
|
||||
const now = new Date()
|
||||
const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`)
|
||||
const now = new Date();
|
||||
const bookingDateTime = new Date(`${booking.date}T${booking.endTime}:00`);
|
||||
|
||||
if (now > bookingDateTime) {
|
||||
return 'NO_SHOW'
|
||||
return 'NO_SHOW';
|
||||
}
|
||||
|
||||
return 'CONFIRMED'
|
||||
return 'CONFIRMED';
|
||||
}
|
||||
|
||||
function effectiveStatusLabel(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
|
||||
if (status === 'NO_SHOW') return 'No show'
|
||||
if (status === 'COMPLETED') return 'Cumplida'
|
||||
if (status === 'CANCELLED') return 'Cancelada'
|
||||
return 'Confirmada'
|
||||
if (status === 'NO_SHOW') return 'No show';
|
||||
if (status === 'COMPLETED') return 'Cumplida';
|
||||
if (status === 'CANCELLED') return 'Cancelada';
|
||||
return 'Confirmada';
|
||||
}
|
||||
|
||||
function effectiveStatusClassName(status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW') {
|
||||
if (status === 'NO_SHOW') {
|
||||
return 'border-orange-200 bg-orange-50 text-orange-700'
|
||||
return 'border-orange-200 bg-orange-50 text-orange-700';
|
||||
}
|
||||
if (status === 'COMPLETED') {
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700';
|
||||
}
|
||||
|
||||
if (status === 'CANCELLED') {
|
||||
return 'border-rose-200 bg-rose-50 text-rose-700'
|
||||
return 'border-rose-200 bg-rose-50 text-rose-700';
|
||||
}
|
||||
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700'
|
||||
return 'border-sky-200 bg-sky-50 text-sky-700';
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const queryClient = useQueryClient()
|
||||
const todayIso = useMemo(() => toIsoDateLocal(new Date()), [])
|
||||
const queryClient = useQueryClient();
|
||||
const todayIso = useMemo(() => toIsoDateLocal(new Date()), []);
|
||||
|
||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null)
|
||||
const [fromDate, setFromDate] = useState(todayIso)
|
||||
const [manualDate, setManualDate] = useState(todayIso)
|
||||
const [selectedSportId, setSelectedSportId] = useState<string | undefined>()
|
||||
const [selectedCourtId, setSelectedCourtId] = useState<string>('')
|
||||
const [selectedStartTime, setSelectedStartTime] = useState<string>('')
|
||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
|
||||
const [fromDate, setFromDate] = useState(todayIso);
|
||||
const [manualDate, setManualDate] = useState(todayIso);
|
||||
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
|
||||
const [selectedCourtId, setSelectedCourtId] = useState<string>('');
|
||||
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(getCurrentComplexSlug())
|
||||
}, [])
|
||||
setCurrentComplexSlug(getCurrentComplexSlug());
|
||||
}, []);
|
||||
|
||||
const myComplexesQuery = useQuery({
|
||||
queryKey: ['my-complexes'],
|
||||
queryFn: () => apiClient.complexes.listMine(),
|
||||
})
|
||||
});
|
||||
|
||||
const selectedComplex = useMemo(() => {
|
||||
const complexes = myComplexesQuery.data ?? []
|
||||
const complexes = myComplexesQuery.data ?? [];
|
||||
|
||||
if (complexes.length === 0) return null
|
||||
if (complexes.length === 0) return null;
|
||||
|
||||
if (currentComplexSlug) {
|
||||
const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug)
|
||||
const match = complexes.find((complex) => complex.complexSlug === currentComplexSlug);
|
||||
|
||||
if (match) return match
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return complexes[0]
|
||||
}, [currentComplexSlug, myComplexesQuery.data])
|
||||
return complexes[0];
|
||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedComplex) return
|
||||
if (!selectedComplex) return;
|
||||
|
||||
setCurrentComplexSlug(selectedComplex.complexSlug)
|
||||
persistCurrentComplexSlug(selectedComplex.complexSlug)
|
||||
}, [selectedComplex])
|
||||
setCurrentComplexSlug(selectedComplex.complexSlug);
|
||||
persistCurrentComplexSlug(selectedComplex.complexSlug);
|
||||
}, [selectedComplex]);
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ['admin-bookings', selectedComplex?.id, fromDate],
|
||||
@@ -156,84 +158,89 @@ export function HomePage() {
|
||||
apiClient.adminBookings.listByComplex(selectedComplex?.id as string, {
|
||||
fromDate,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
const manualAvailabilityQuery = useQuery({
|
||||
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug, manualDate, selectedSportId],
|
||||
queryKey: [
|
||||
'manual-booking-availability',
|
||||
selectedComplex?.complexSlug,
|
||||
manualDate,
|
||||
selectedSportId,
|
||||
],
|
||||
enabled: Boolean(selectedComplex?.complexSlug && manualDate),
|
||||
queryFn: () =>
|
||||
apiClient.publicBookings.getAvailability(selectedComplex?.complexSlug as string, {
|
||||
date: manualDate,
|
||||
...(selectedSportId ? { sportId: selectedSportId } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const availability = manualAvailabilityQuery.data
|
||||
const availability = manualAvailabilityQuery.data;
|
||||
|
||||
if (!availability) return
|
||||
if (!availability) return;
|
||||
|
||||
if (!availability.sportSelectionRequired) {
|
||||
if (availability.sports[0] && !selectedSportId) {
|
||||
setSelectedSportId(availability.sports[0].id)
|
||||
setSelectedSportId(availability.sports[0].id);
|
||||
}
|
||||
} else if (
|
||||
selectedSportId &&
|
||||
!availability.sports.some((sport) => sport.id === selectedSportId)
|
||||
) {
|
||||
setSelectedSportId(undefined)
|
||||
setSelectedSportId(undefined);
|
||||
}
|
||||
}, [manualAvailabilityQuery.data, selectedSportId])
|
||||
}, [manualAvailabilityQuery.data, selectedSportId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manualAvailabilityQuery.data) return
|
||||
if (!manualAvailabilityQuery.data) return;
|
||||
|
||||
if (
|
||||
selectedCourtId &&
|
||||
!manualAvailabilityQuery.data.courts.some((court) => court.courtId === selectedCourtId)
|
||||
) {
|
||||
setSelectedCourtId('')
|
||||
setSelectedStartTime('')
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}
|
||||
}, [manualAvailabilityQuery.data, selectedCourtId])
|
||||
}, [manualAvailabilityQuery.data, selectedCourtId]);
|
||||
|
||||
const selectedCourt = useMemo(() => {
|
||||
return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId)
|
||||
}, [manualAvailabilityQuery.data?.courts, selectedCourtId])
|
||||
return manualAvailabilityQuery.data?.courts.find((court) => court.courtId === selectedCourtId);
|
||||
}, [manualAvailabilityQuery.data?.courts, selectedCourtId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCourt) {
|
||||
setSelectedStartTime('')
|
||||
return
|
||||
setSelectedStartTime('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedCourt.availableSlots.some((slot) => slot.startTime === selectedStartTime)) {
|
||||
setSelectedStartTime('')
|
||||
setSelectedStartTime('');
|
||||
}
|
||||
}, [selectedCourt, selectedStartTime])
|
||||
}, [selectedCourt, selectedStartTime]);
|
||||
|
||||
const groupedBookings = useMemo(() => {
|
||||
const groups = new Map<string, AdminBooking[]>()
|
||||
const groups = new Map<string, AdminBooking[]>();
|
||||
|
||||
for (const booking of bookingsQuery.data?.bookings ?? []) {
|
||||
const current = groups.get(booking.date) ?? []
|
||||
current.push(booking)
|
||||
groups.set(booking.date, current)
|
||||
const current = groups.get(booking.date) ?? [];
|
||||
current.push(booking);
|
||||
groups.set(booking.date, current);
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
}, [bookingsQuery.data?.bookings])
|
||||
return [...groups.entries()];
|
||||
}, [bookingsQuery.data?.bookings]);
|
||||
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: (payload: { bookingId: string; status: 'CANCELLED' | 'COMPLETED' }) =>
|
||||
apiClient.adminBookings.updateStatus(payload.bookingId, { status: payload.status }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
|
||||
})
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -247,16 +254,16 @@ export function HomePage() {
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const createManualBookingMutation = useMutation({
|
||||
mutationFn: async (values: ManualBookingForm) => {
|
||||
if (!selectedComplex?.id) {
|
||||
throw new Error('No se encontró el complejo actual.')
|
||||
throw new Error('No se encontró el complejo actual.');
|
||||
}
|
||||
|
||||
if (!selectedCourtId || !selectedStartTime) {
|
||||
throw new Error('Debes seleccionar cancha y horario.')
|
||||
throw new Error('Debes seleccionar cancha y horario.');
|
||||
}
|
||||
|
||||
return apiClient.adminBookings.create(selectedComplex.id, {
|
||||
@@ -265,30 +272,30 @@ export function HomePage() {
|
||||
startTime: selectedStartTime,
|
||||
customerName: values.customerName,
|
||||
customerPhone: values.customerPhone,
|
||||
})
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
reset()
|
||||
setSelectedCourtId('')
|
||||
setSelectedStartTime('')
|
||||
reset();
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex?.id] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['manual-booking-availability', selectedComplex?.complexSlug],
|
||||
})
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const onSubmitManualBooking = async (values: ManualBookingForm) => {
|
||||
await createManualBookingMutation.mutateAsync(values)
|
||||
}
|
||||
await createManualBookingMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
if (myComplexesQuery.isLoading) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">Cargando panel...</p>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (myComplexesQuery.isError) {
|
||||
@@ -298,17 +305,15 @@ export function HomePage() {
|
||||
{extractMessage(myComplexesQuery.error, 'No pudimos cargar tus complejos.')}
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedComplex) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-6 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No tienes complejos asignados todavía.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">No tienes complejos asignados todavía.</p>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -327,15 +332,15 @@ export function HomePage() {
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(fromDate)}
|
||||
onChange={(date) => {
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso
|
||||
setFromDate(isoDate)
|
||||
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
|
||||
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"
|
||||
/>
|
||||
@@ -352,13 +357,11 @@ export function HomePage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!bookingsQuery.isLoading &&
|
||||
!bookingsQuery.isError &&
|
||||
groupedBookings.length === 0 && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No hay reservas para la fecha seleccionada en adelante.
|
||||
</p>
|
||||
)}
|
||||
{!bookingsQuery.isLoading && !bookingsQuery.isError && groupedBookings.length === 0 && (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No hay reservas para la fecha seleccionada en adelante.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{groupedBookings.map(([date, bookings]) => (
|
||||
@@ -366,9 +369,10 @@ export function HomePage() {
|
||||
<h2 className="text-sm font-semibold capitalize">{formatDateLabel(date)}</h2>
|
||||
<div className="mt-3 space-y-2">
|
||||
{bookings.map((booking) => {
|
||||
const effectiveStatus = getEffectiveStatus(booking)
|
||||
const canManage = booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED'
|
||||
const isMutating = updateStatusMutation.isPending
|
||||
const effectiveStatus = getEffectiveStatus(booking);
|
||||
const canManage =
|
||||
booking.status === 'CONFIRMED' && effectiveStatus === 'CONFIRMED';
|
||||
const isMutating = updateStatusMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -380,7 +384,8 @@ export function HomePage() {
|
||||
{booking.startTime} - {booking.endTime} · {booking.courtName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{booking.customerName} ({booking.customerPhone}) · Código {booking.bookingCode}
|
||||
{booking.customerName} ({booking.customerPhone}) · Código{' '}
|
||||
{booking.bookingCode}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -402,7 +407,7 @@ export function HomePage() {
|
||||
updateStatusMutation.mutate({
|
||||
bookingId: booking.id,
|
||||
status: 'COMPLETED',
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
Marcar cumplida
|
||||
@@ -416,7 +421,7 @@ export function HomePage() {
|
||||
updateStatusMutation.mutate({
|
||||
bookingId: booking.id,
|
||||
status: 'CANCELLED',
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
@@ -425,7 +430,7 @@ export function HomePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
@@ -451,10 +456,10 @@ export function HomePage() {
|
||||
<DatePicker
|
||||
value={fromIsoDateLocal(manualDate)}
|
||||
onChange={(date) => {
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso
|
||||
setManualDate(isoDate)
|
||||
setSelectedCourtId('')
|
||||
setSelectedStartTime('')
|
||||
const isoDate = date ? toIsoDateLocal(date) : todayIso;
|
||||
setManualDate(isoDate);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
minDate={new Date()}
|
||||
placeholder="Selecciona una fecha"
|
||||
@@ -467,9 +472,9 @@ export function HomePage() {
|
||||
<Select
|
||||
value={selectedSportId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSportId(value)
|
||||
setSelectedCourtId('')
|
||||
setSelectedStartTime('')
|
||||
setSelectedSportId(value);
|
||||
setSelectedCourtId('');
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -491,8 +496,8 @@ export function HomePage() {
|
||||
<Select
|
||||
value={selectedCourtId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedCourtId(value)
|
||||
setSelectedStartTime('')
|
||||
setSelectedCourtId(value);
|
||||
setSelectedStartTime('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -535,12 +540,15 @@ export function HomePage() {
|
||||
<p className="mt-3 text-sm text-destructive">
|
||||
{extractMessage(
|
||||
manualAvailabilityQuery.error,
|
||||
'No pudimos cargar disponibilidad para la reserva manual.',
|
||||
'No pudimos cargar disponibilidad para la reserva manual.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="mt-4 grid gap-3 md:grid-cols-2" onSubmit={handleSubmit(onSubmitManualBooking)}>
|
||||
<form
|
||||
className="mt-4 grid gap-3 md:grid-cols-2"
|
||||
onSubmit={handleSubmit(onSubmitManualBooking)}
|
||||
>
|
||||
<Field data-invalid={Boolean(errors.customerName)}>
|
||||
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
|
||||
<Input
|
||||
@@ -568,7 +576,7 @@ export function HomePage() {
|
||||
<p className="text-sm text-destructive md:col-span-2">
|
||||
{extractMessage(
|
||||
createManualBookingMutation.error,
|
||||
'No pudimos crear la reserva manual.',
|
||||
'No pudimos crear la reserva manual.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -583,5 +591,5 @@ export function HomePage() {
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LogOut, Menu, UserRound } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -11,54 +7,56 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import {
|
||||
getCurrentComplexSlug,
|
||||
setCurrentComplexSlug as persistCurrentComplexSlug,
|
||||
} from '@/lib/current-complex'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { ThemeSwitcher } from './theme-switcher'
|
||||
} from '@/lib/current-complex';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { LogOut, Menu, UserRound } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ThemeSwitcher } from './theme-switcher';
|
||||
|
||||
export function RootLayout() {
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth()
|
||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null)
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, displayName, initials, avatarUrl, signOut } = useAuth();
|
||||
const [currentComplexSlug, setCurrentComplexSlug] = useState<string | null>(null);
|
||||
const myComplexesQuery = useQuery({
|
||||
queryKey: ['my-complexes'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => apiClient.complexes.listMine(),
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentComplexSlug(getCurrentComplexSlug())
|
||||
}, [])
|
||||
setCurrentComplexSlug(getCurrentComplexSlug());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentComplexSlug) return
|
||||
if (currentComplexSlug) return;
|
||||
|
||||
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug
|
||||
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug;
|
||||
if (fallbackSlug) {
|
||||
setCurrentComplexSlug(fallbackSlug)
|
||||
persistCurrentComplexSlug(fallbackSlug)
|
||||
setCurrentComplexSlug(fallbackSlug);
|
||||
persistCurrentComplexSlug(fallbackSlug);
|
||||
}
|
||||
}, [currentComplexSlug, myComplexesQuery.data])
|
||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
||||
|
||||
const currentComplexName = useMemo(() => {
|
||||
const complexes = myComplexesQuery.data ?? []
|
||||
if (complexes.length === 0) return 'Mi complejo'
|
||||
const complexes = myComplexesQuery.data ?? [];
|
||||
if (complexes.length === 0) return 'Mi complejo';
|
||||
|
||||
const selected = complexes.find(
|
||||
(complex) => complex.complexSlug === currentComplexSlug,
|
||||
)
|
||||
const selected = complexes.find((complex) => complex.complexSlug === currentComplexSlug);
|
||||
|
||||
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo'
|
||||
}, [currentComplexSlug, myComplexesQuery.data])
|
||||
return selected?.complexName ?? complexes[0]?.complexName ?? 'Mi complejo';
|
||||
}, [currentComplexSlug, myComplexesQuery.data]);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
await navigate({ to: '/login' })
|
||||
}
|
||||
await signOut();
|
||||
await navigate({ to: '/login' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
@@ -106,9 +104,7 @@ export function RootLayout() {
|
||||
<AvatarImage src={avatarUrl ?? undefined} alt={displayName} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-32 truncate text-sm text-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="max-w-32 truncate text-sm text-foreground">{displayName}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
@@ -116,7 +112,7 @@ export function RootLayout() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/profile' })
|
||||
void navigate({ to: '/profile' });
|
||||
}}
|
||||
>
|
||||
<UserRound className="size-4" />
|
||||
@@ -125,7 +121,7 @@ export function RootLayout() {
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
void handleSignOut()
|
||||
void handleSignOut();
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
@@ -152,14 +148,14 @@ export function RootLayout() {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/' })
|
||||
void navigate({ to: '/' });
|
||||
}}
|
||||
>
|
||||
Home
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/about' })
|
||||
void navigate({ to: '/about' });
|
||||
}}
|
||||
>
|
||||
About
|
||||
@@ -170,7 +166,7 @@ export function RootLayout() {
|
||||
void navigate({
|
||||
to: '/complex/$slug/edit',
|
||||
params: { slug: currentComplexSlug },
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
Configuración
|
||||
@@ -182,7 +178,7 @@ export function RootLayout() {
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/profile' })
|
||||
void navigate({ to: '/profile' });
|
||||
}}
|
||||
>
|
||||
<UserRound className="size-4" />
|
||||
@@ -191,7 +187,7 @@ export function RootLayout() {
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
void handleSignOut()
|
||||
void handleSignOut();
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
@@ -201,7 +197,7 @@ export function RootLayout() {
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/login' })
|
||||
void navigate({ to: '/login' });
|
||||
}}
|
||||
>
|
||||
Login
|
||||
@@ -215,5 +211,5 @@ export function RootLayout() {
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Laptop, Moon, Sun } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -8,21 +7,18 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTheme } from '@/lib/theme'
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useTheme } from '@/lib/theme';
|
||||
import { Laptop, Moon, Sun } from 'lucide-react';
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme()
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="px-2">
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<Moon className="size-4" />
|
||||
) : (
|
||||
<Sun className="size-4" />
|
||||
)}
|
||||
{resolvedTheme === 'dark' ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
@@ -47,5 +43,5 @@ export function ThemeSwitcher() {
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Ingresá un email válido.'),
|
||||
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres.'),
|
||||
})
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
type LoginPageProps = {
|
||||
redirectTo?: string
|
||||
}
|
||||
redirectTo?: string;
|
||||
};
|
||||
|
||||
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const { signInWithPassword } = useAuth()
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const navigate = useNavigate();
|
||||
const { signInWithPassword } = useAuth();
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -35,18 +35,18 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const onSubmit = async (values: LoginForm) => {
|
||||
setSubmitError(null)
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
await signInWithPassword(values)
|
||||
await navigate({ to: redirectTo })
|
||||
await signInWithPassword(values);
|
||||
await navigate({ to: redirectTo });
|
||||
} catch {
|
||||
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.')
|
||||
setSubmitError('No pudimos iniciar sesión. Verificá tus credenciales.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center px-6">
|
||||
@@ -96,5 +96,5 @@ export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Compass, Home, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Compass, Home, Sparkles } from 'lucide-react';
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
@@ -16,7 +16,8 @@ export function NotFoundPage() {
|
||||
<p className="text-sm font-medium tracking-wide text-muted-foreground">404</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Página no encontrada</h1>
|
||||
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
|
||||
La ruta que intentaste abrir no existe o fue movida. Volvé al inicio o andá al onboarding para continuar.
|
||||
La ruta que intentaste abrir no existe o fue movida. Volvé al inicio o andá al onboarding
|
||||
para continuar.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
@@ -36,5 +37,5 @@ export function NotFoundPage() {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ export function OnboardingCheckEmailStep() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Revisa tu correo y abre el link de verificacion para continuar.
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { PlanSummary } from '@repo/api-contract'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { CompleteValues } from '@/features/onboard/onboarding.types'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { CompleteValues } from '@/features/onboard/onboarding.types';
|
||||
import type { PlanSummary } from '@repo/api-contract';
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type OnboardingCompleteStepProps = {
|
||||
form: UseFormReturn<CompleteValues>
|
||||
plans: PlanSummary[]
|
||||
verifiedEmail: string | null
|
||||
errorMessage: string | null
|
||||
infoMessage: string | null
|
||||
planPlaceholder: string
|
||||
onSubmit: (values: CompleteValues) => Promise<void>
|
||||
}
|
||||
form: UseFormReturn<CompleteValues>;
|
||||
plans: PlanSummary[];
|
||||
verifiedEmail: string | null;
|
||||
errorMessage: string | null;
|
||||
infoMessage: string | null;
|
||||
planPlaceholder: string;
|
||||
onSubmit: (values: CompleteValues) => Promise<void>;
|
||||
};
|
||||
|
||||
export function OnboardingCompleteStep({
|
||||
form,
|
||||
@@ -81,7 +81,7 @@ export function OnboardingCompleteStep({
|
||||
aria-invalid={Boolean(form.formState.errors.planCode)}
|
||||
value={field.value ?? ''}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value)
|
||||
field.onChange(event.target.value);
|
||||
}}
|
||||
disabled={plans.length === 0}
|
||||
>
|
||||
@@ -115,5 +115,5 @@ export function OnboardingCompleteStep({
|
||||
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
type OnboardingLayoutProps = PropsWithChildren<{
|
||||
title: string
|
||||
description: string
|
||||
}>
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
export function OnboardingLayout({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: OnboardingLayoutProps) {
|
||||
export function OnboardingLayout({ title, description, children }: OnboardingLayoutProps) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
|
||||
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
@@ -18,5 +14,5 @@ export function OnboardingLayout({
|
||||
{children}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { StartValues } from '@/features/onboard/onboarding.types'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { StartValues } from '@/features/onboard/onboarding.types';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type OnboardingStartStepProps = {
|
||||
form: UseFormReturn<StartValues>
|
||||
errorMessage: string | null
|
||||
infoMessage: string | null
|
||||
onSubmit: (values: StartValues) => Promise<void>
|
||||
}
|
||||
form: UseFormReturn<StartValues>;
|
||||
errorMessage: string | null;
|
||||
infoMessage: string | null;
|
||||
onSubmit: (values: StartValues) => Promise<void>;
|
||||
};
|
||||
|
||||
export function OnboardingStartStep({
|
||||
form,
|
||||
@@ -54,5 +54,5 @@ export function OnboardingStartStep({
|
||||
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp'
|
||||
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types'
|
||||
} from '@/components/ui/input-otp';
|
||||
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
type OnboardingVerifyOtpStepProps = {
|
||||
form: UseFormReturn<VerifyOtpValues>
|
||||
email: string | null
|
||||
errorMessage: string | null
|
||||
infoMessage: string | null
|
||||
remainingAttempts: number
|
||||
resendCooldownSeconds: number
|
||||
isResending: boolean
|
||||
onSubmit: (values: VerifyOtpValues) => Promise<void>
|
||||
onResend: () => Promise<void>
|
||||
}
|
||||
form: UseFormReturn<VerifyOtpValues>;
|
||||
email: string | null;
|
||||
errorMessage: string | null;
|
||||
infoMessage: string | null;
|
||||
remainingAttempts: number;
|
||||
resendCooldownSeconds: number;
|
||||
isResending: boolean;
|
||||
onSubmit: (values: VerifyOtpValues) => Promise<void>;
|
||||
onResend: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function OnboardingVerifyOtpStep({
|
||||
form,
|
||||
@@ -33,7 +33,7 @@ export function OnboardingVerifyOtpStep({
|
||||
onSubmit,
|
||||
onResend,
|
||||
}: OnboardingVerifyOtpStepProps) {
|
||||
const resendDisabled = resendCooldownSeconds > 0 || isResending
|
||||
const resendDisabled = resendCooldownSeconds > 0 || isResending;
|
||||
|
||||
return (
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
@@ -51,7 +51,7 @@ export function OnboardingVerifyOtpStep({
|
||||
size="sm"
|
||||
disabled={resendDisabled}
|
||||
onClick={() => {
|
||||
void onResend()
|
||||
void onResend();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
@@ -70,7 +70,7 @@ export function OnboardingVerifyOtpStep({
|
||||
pattern="\d*"
|
||||
value={form.watch('otp')}
|
||||
onChange={(value) => {
|
||||
form.setValue('otp', value, { shouldValidate: true })
|
||||
form.setValue('otp', value, { shouldValidate: true });
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
@@ -104,5 +104,5 @@ export function OnboardingVerifyOtpStep({
|
||||
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
export function OnboardingVerifyingStep() {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Verificando tu email, espera un momento...
|
||||
</p>
|
||||
)
|
||||
<p className="text-sm text-muted-foreground">Verificando tu email, espera un momento...</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
onboardingCompleteSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
type PlanSummary,
|
||||
} from '@repo/api-contract'
|
||||
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step'
|
||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout'
|
||||
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step'
|
||||
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step'
|
||||
import { OnboardingCompleteStep } from '@/features/onboard/components/onboarding-complete-step';
|
||||
import { OnboardingLayout } from '@/features/onboard/components/onboarding-layout';
|
||||
import { OnboardingStartStep } from '@/features/onboard/components/onboarding-start-step';
|
||||
import { OnboardingVerifyOtpStep } from '@/features/onboard/components/onboarding-verify-otp-step';
|
||||
import type {
|
||||
CompleteValues,
|
||||
StartValues,
|
||||
VerifyOtpValues,
|
||||
} from '@/features/onboard/onboarding.types'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex'
|
||||
} from '@/features/onboard/onboarding.types';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { setCurrentComplexSlug } from '@/lib/current-complex';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
type PlanSummary,
|
||||
onboardingCompleteSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
} from '@repo/api-contract';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
const OTP_MAX_ATTEMPTS = 5
|
||||
type OnboardStep = 'start' | 'verify-otp' | 'complete'
|
||||
const OTP_MAX_ATTEMPTS = 5;
|
||||
type OnboardStep = 'start' | 'verify-otp' | 'complete';
|
||||
|
||||
export function OnboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { signInWithPassword } = useAuth()
|
||||
const navigate = useNavigate();
|
||||
const { signInWithPassword } = useAuth();
|
||||
|
||||
const [step, setStep] = useState<OnboardStep>('start')
|
||||
const [requestId, setRequestId] = useState<string | null>(null)
|
||||
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null)
|
||||
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null)
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([])
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS)
|
||||
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0)
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null)
|
||||
const [step, setStep] = useState<OnboardStep>('start');
|
||||
const [requestId, setRequestId] = useState<string | null>(null);
|
||||
const [onboardingEmail, setOnboardingEmail] = useState<string | null>(null);
|
||||
const [verifiedEmail, setVerifiedEmail] = useState<string | null>(null);
|
||||
const [plans, setPlans] = useState<PlanSummary[]>([]);
|
||||
const [remainingAttempts, setRemainingAttempts] = useState<number>(OTP_MAX_ATTEMPTS);
|
||||
const [resendCooldownSeconds, setResendCooldownSeconds] = useState<number>(0);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [infoMessage, setInfoMessage] = useState<string | null>(null);
|
||||
|
||||
const startForm = useForm<StartValues>({
|
||||
resolver: zodResolver(onboardingStartSchema),
|
||||
@@ -46,12 +46,10 @@ export function OnboardPage() {
|
||||
fullName: '',
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const completeForm = useForm<CompleteValues>({
|
||||
resolver: zodResolver(
|
||||
onboardingCompleteSchema.omit({ onboardingRequestId: true }),
|
||||
),
|
||||
resolver: zodResolver(onboardingCompleteSchema.omit({ onboardingRequestId: true })),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
password: '',
|
||||
@@ -59,7 +57,7 @@ export function OnboardPage() {
|
||||
physicalAddress: '',
|
||||
planCode: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const verifyOtpForm = useForm<VerifyOtpValues>({
|
||||
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
|
||||
@@ -67,114 +65,114 @@ export function OnboardPage() {
|
||||
defaultValues: {
|
||||
otp: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'complete') return
|
||||
if (step !== 'complete') return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await apiClient.plans.list()
|
||||
setPlans(result)
|
||||
const result = await apiClient.plans.list();
|
||||
setPlans(result);
|
||||
} catch {
|
||||
setPlans([])
|
||||
setPlans([]);
|
||||
}
|
||||
})()
|
||||
}, [step])
|
||||
})();
|
||||
}, [step]);
|
||||
|
||||
const planPlaceholder = useMemo(() => {
|
||||
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)'
|
||||
return 'Selecciona un plan'
|
||||
}, [plans.length])
|
||||
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)';
|
||||
return 'Selecciona un plan';
|
||||
}, [plans.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendCooldownSeconds <= 0) return
|
||||
if (resendCooldownSeconds <= 0) return;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setResendCooldownSeconds((current) => Math.max(0, current - 1))
|
||||
}, 1000)
|
||||
setResendCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [resendCooldownSeconds])
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [resendCooldownSeconds]);
|
||||
|
||||
const onSubmitStart = async (values: StartValues) => {
|
||||
setErrorMessage(null)
|
||||
setInfoMessage(null)
|
||||
setErrorMessage(null);
|
||||
setInfoMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.start(values)
|
||||
setRequestId(result.requestId)
|
||||
setOnboardingEmail(result.email)
|
||||
setRemainingAttempts(OTP_MAX_ATTEMPTS)
|
||||
setResendCooldownSeconds(result.cooldownSeconds)
|
||||
verifyOtpForm.reset({ otp: '' })
|
||||
setStep('verify-otp')
|
||||
setInfoMessage(result.message)
|
||||
const result = await apiClient.onboarding.start(values);
|
||||
setRequestId(result.requestId);
|
||||
setOnboardingEmail(result.email);
|
||||
setRemainingAttempts(OTP_MAX_ATTEMPTS);
|
||||
setResendCooldownSeconds(result.cooldownSeconds);
|
||||
verifyOtpForm.reset({ otp: '' });
|
||||
setStep('verify-otp');
|
||||
setInfoMessage(result.message);
|
||||
} catch {
|
||||
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.')
|
||||
setErrorMessage('No pudimos iniciar el onboarding. Intenta nuevamente.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitVerifyOtp = async (values: VerifyOtpValues) => {
|
||||
if (!requestId) {
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.')
|
||||
return
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.verifyOtp({
|
||||
requestId,
|
||||
otp: values.otp,
|
||||
})
|
||||
});
|
||||
|
||||
setRemainingAttempts(result.remainingAttempts)
|
||||
setResendCooldownSeconds(result.cooldownSeconds)
|
||||
setInfoMessage(result.message)
|
||||
setRemainingAttempts(result.remainingAttempts);
|
||||
setResendCooldownSeconds(result.cooldownSeconds);
|
||||
setInfoMessage(result.message);
|
||||
|
||||
if (result.verified) {
|
||||
setVerifiedEmail(result.email ?? onboardingEmail)
|
||||
setStep('complete')
|
||||
return
|
||||
setVerifiedEmail(result.email ?? onboardingEmail);
|
||||
setStep('complete');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.')
|
||||
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onResendOtp = async () => {
|
||||
if (!requestId) {
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.')
|
||||
return
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResending(true)
|
||||
setErrorMessage(null)
|
||||
setIsResending(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.resendOtp({ requestId })
|
||||
setOnboardingEmail(result.email)
|
||||
setRemainingAttempts(result.remainingAttempts)
|
||||
setResendCooldownSeconds(result.cooldownSeconds)
|
||||
verifyOtpForm.reset({ otp: '' })
|
||||
setInfoMessage(result.message)
|
||||
const result = await apiClient.onboarding.resendOtp({ requestId });
|
||||
setOnboardingEmail(result.email);
|
||||
setRemainingAttempts(result.remainingAttempts);
|
||||
setResendCooldownSeconds(result.cooldownSeconds);
|
||||
verifyOtpForm.reset({ otp: '' });
|
||||
setInfoMessage(result.message);
|
||||
} catch {
|
||||
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.')
|
||||
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.');
|
||||
} finally {
|
||||
setIsResending(false)
|
||||
setIsResending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitComplete = async (values: CompleteValues) => {
|
||||
if (!requestId) {
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.')
|
||||
return
|
||||
setErrorMessage('No encontramos una solicitud de onboarding activa.');
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await apiClient.onboarding.complete({
|
||||
@@ -183,19 +181,19 @@ export function OnboardPage() {
|
||||
complexName: values.complexName,
|
||||
physicalAddress: values.physicalAddress,
|
||||
planCode: values.planCode,
|
||||
})
|
||||
setCurrentComplexSlug(result.complexSlug)
|
||||
});
|
||||
setCurrentComplexSlug(result.complexSlug);
|
||||
|
||||
const emailForSignIn = verifiedEmail ?? onboardingEmail
|
||||
const emailForSignIn = verifiedEmail ?? onboardingEmail;
|
||||
if (emailForSignIn) {
|
||||
await signInWithPassword({ email: emailForSignIn, password: values.password })
|
||||
await signInWithPassword({ email: emailForSignIn, password: values.password });
|
||||
}
|
||||
|
||||
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } })
|
||||
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } });
|
||||
} catch {
|
||||
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.')
|
||||
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
@@ -237,5 +235,5 @@ export function OnboardPage() {
|
||||
/>
|
||||
)}
|
||||
</OnboardingLayout>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,9 @@ import {
|
||||
onboardingCompleteSchema,
|
||||
onboardingStartSchema,
|
||||
onboardingVerifyOtpSchema,
|
||||
} from '@repo/api-contract'
|
||||
import { z } from 'zod'
|
||||
} from '@repo/api-contract';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type StartValues = z.infer<typeof onboardingStartSchema>
|
||||
export type VerifyOtpValues = Omit<
|
||||
z.infer<typeof onboardingVerifyOtpSchema>,
|
||||
'requestId'
|
||||
>
|
||||
export type CompleteValues = Omit<
|
||||
z.infer<typeof onboardingCompleteSchema>,
|
||||
'onboardingRequestId'
|
||||
>
|
||||
export type StartValues = z.infer<typeof onboardingStartSchema>;
|
||||
export type VerifyOtpValues = Omit<z.infer<typeof onboardingVerifyOtpSchema>, 'requestId'>;
|
||||
export type CompleteValues = Omit<z.infer<typeof onboardingCompleteSchema>, 'onboardingRequestId'>;
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { UserProfileResponse } from '@repo/api-contract'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -13,42 +7,51 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { UserProfileResponse } from '@repo/api-contract';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
fullName: z.string().min(1, 'El nombre es requerido'),
|
||||
})
|
||||
});
|
||||
|
||||
const updatePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'La contraseña actual es requerida'),
|
||||
newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'),
|
||||
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
const updatePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'La contraseña actual es requerida'),
|
||||
newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'),
|
||||
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type UpdateProfileForm = z.infer<typeof updateProfileSchema>
|
||||
type UpdatePasswordForm = z.infer<typeof updatePasswordSchema>
|
||||
type UpdateProfileForm = z.infer<typeof updateProfileSchema>;
|
||||
type UpdatePasswordForm = z.infer<typeof updatePasswordSchema>;
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const { user, displayName, initials, avatarUrl, isAuthenticated, updateProfile, updatePassword } =
|
||||
useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
enabled: isAuthenticated,
|
||||
queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(),
|
||||
})
|
||||
});
|
||||
|
||||
const updateProfileForm = useForm<UpdateProfileForm>({
|
||||
resolver: zodResolver(updateProfileSchema),
|
||||
defaultValues: {
|
||||
fullName: displayName,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updatePasswordForm = useForm<UpdatePasswordForm>({
|
||||
resolver: zodResolver(updatePasswordSchema),
|
||||
@@ -57,34 +60,34 @@ export function ProfilePage() {
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: updateProfile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] })
|
||||
updateProfileForm.reset()
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
updateProfileForm.reset();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updatePasswordMutation = useMutation({
|
||||
mutationFn: async (data: UpdatePasswordForm) => {
|
||||
// Note: Supabase doesn't require current password for password update
|
||||
// The user needs to be recently authenticated
|
||||
await updatePassword({ password: data.newPassword })
|
||||
await updatePassword({ password: data.newPassword });
|
||||
},
|
||||
onSuccess: () => {
|
||||
updatePasswordForm.reset()
|
||||
updatePasswordForm.reset();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const onUpdateProfile = (data: UpdateProfileForm) => {
|
||||
updateProfileMutation.mutate(data)
|
||||
}
|
||||
updateProfileMutation.mutate(data);
|
||||
};
|
||||
|
||||
const onUpdatePassword = (data: UpdatePasswordForm) => {
|
||||
updatePasswordMutation.mutate(data)
|
||||
}
|
||||
updatePasswordMutation.mutate(data);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
@@ -99,7 +102,7 @@ export function ProfilePage() {
|
||||
</Button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -122,9 +125,7 @@ export function ProfilePage() {
|
||||
{profileQuery.data?.email ?? user?.email}
|
||||
</p>
|
||||
{profileQuery.data?.role && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rol: {profileQuery.data.role}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Rol: {profileQuery.data.role}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,10 +158,7 @@ export function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
<Button type="submit" disabled={updateProfileMutation.isPending}>
|
||||
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -171,16 +169,17 @@ export function ProfilePage() {
|
||||
</p>
|
||||
)}
|
||||
{updateProfileMutation.isSuccess && (
|
||||
<p className="mt-4 text-sm text-green-600">
|
||||
Perfil actualizado exitosamente.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-green-600">Perfil actualizado exitosamente.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2>
|
||||
<Form {...updatePasswordForm}>
|
||||
<form onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)} className="space-y-4">
|
||||
<form
|
||||
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={updatePasswordForm.control}
|
||||
name="currentPassword"
|
||||
@@ -220,10 +219,7 @@ export function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updatePasswordMutation.isPending}
|
||||
>
|
||||
<Button type="submit" disabled={updatePasswordMutation.isPending}>
|
||||
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -234,12 +230,10 @@ export function ProfilePage() {
|
||||
</p>
|
||||
)}
|
||||
{updatePasswordMutation.isSuccess && (
|
||||
<p className="mt-4 text-sm text-green-600">
|
||||
Contraseña cambiada exitosamente.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-green-600">Contraseña cambiada exitosamente.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { PublicBookingConfirmation } from '@repo/api-contract'
|
||||
import { siWhatsapp } from 'simple-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { PublicBookingConfirmation } from '@repo/api-contract';
|
||||
import { siWhatsapp } from 'simple-icons';
|
||||
|
||||
type ShareWhatsappButtonProps = {
|
||||
confirmation: PublicBookingConfirmation
|
||||
}
|
||||
confirmation: PublicBookingConfirmation;
|
||||
};
|
||||
|
||||
function formatDateLabel(isoDate: string) {
|
||||
const [year, month, day] = isoDate.split('-').map(Number)
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1)
|
||||
const [year, month, day] = isoDate.split('-').map(Number);
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
|
||||
return new Intl.DateTimeFormat('es-AR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
||||
const dateLabel = formatDateLabel(confirmation.date)
|
||||
const dateLabel = formatDateLabel(confirmation.date);
|
||||
return [
|
||||
'Ya reservamos cancha para jugar.',
|
||||
`Complejo: ${confirmation.complexName}`,
|
||||
@@ -27,13 +27,13 @@ function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
||||
`Fecha: ${dateLabel}`,
|
||||
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
|
||||
`Codigo de reserva: ${confirmation.bookingCode}`,
|
||||
].join('\n')
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
|
||||
const whatsappUrl = `https://wa.me/?text=${encodeURIComponent(
|
||||
createWhatsappMessage(confirmation),
|
||||
)}`
|
||||
createWhatsappMessage(confirmation)
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild>
|
||||
@@ -49,5 +49,5 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
||||
Compartir por WhatsApp
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client'
|
||||
import { ShareWhatsappButton } from './components/share-whatsapp-button'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
||||
|
||||
type PublicBookingConfirmationPageProps = {
|
||||
complexSlug: string
|
||||
bookingCode: string
|
||||
}
|
||||
complexSlug: string;
|
||||
bookingCode: string;
|
||||
};
|
||||
|
||||
function extractMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof ApiClientError) {
|
||||
return error.message || fallback
|
||||
return error.message || fallback;
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatDateLabel(isoDate: string) {
|
||||
const [year, month, day] = isoDate.split('-').map(Number)
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1)
|
||||
const [year, month, day] = isoDate.split('-').map(Number);
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
|
||||
return new Intl.DateTimeFormat('es-AR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function PublicBookingConfirmationPage({
|
||||
complexSlug,
|
||||
bookingCode,
|
||||
}: PublicBookingConfirmationPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
const confirmationQuery = useQuery({
|
||||
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
|
||||
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
|
||||
@@ -51,7 +51,7 @@ export function PublicBookingConfirmationPage({
|
||||
<p className="text-sm text-destructive">
|
||||
{extractMessage(
|
||||
confirmationQuery.error,
|
||||
'No pudimos cargar la confirmacion de la reserva.',
|
||||
'No pudimos cargar la confirmacion de la reserva.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -100,7 +100,7 @@ export function PublicBookingConfirmationPage({
|
||||
void navigate({
|
||||
to: '/$complexSlug/booking',
|
||||
params: { complexSlug },
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
Hacer otra reserva
|
||||
@@ -111,5 +111,5 @@ export function PublicBookingConfirmationPage({
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client'
|
||||
} from '@/components/ui/select';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DAYS_STEP = 5
|
||||
const DAYS_STEP = 5;
|
||||
|
||||
const bookingFormSchema = z.object({
|
||||
customerName: z
|
||||
@@ -29,93 +29,93 @@ const bookingFormSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'Ingresa un telefono valido.')
|
||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||
})
|
||||
});
|
||||
|
||||
type BookingFormValues = z.infer<typeof bookingFormSchema>
|
||||
type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||
|
||||
type PublicBookingPageProps = {
|
||||
complexSlug: string
|
||||
}
|
||||
complexSlug: string;
|
||||
};
|
||||
|
||||
type SelectedSlot = {
|
||||
courtId: string
|
||||
courtName: string
|
||||
sportId: string
|
||||
sportName: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
courtId: string;
|
||||
courtName: string;
|
||||
sportId: string;
|
||||
sportName: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
function addDays(baseDate: Date, amount: number) {
|
||||
const next = new Date(baseDate)
|
||||
next.setDate(next.getDate() + amount)
|
||||
return next
|
||||
const next = new Date(baseDate);
|
||||
next.setDate(next.getDate() + amount);
|
||||
return next;
|
||||
}
|
||||
|
||||
function toIsoDateLocal(date: Date) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function toMinutes(value: string): number {
|
||||
const [hours, minutes] = value.split(':').map((part) => Number(part))
|
||||
return hours * 60 + minutes
|
||||
const [hours, minutes] = value.split(':').map((part) => Number(part));
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function formatDayLabel(date: Date) {
|
||||
const weekday = new Intl.DateTimeFormat('es-AR', { weekday: 'short' }).format(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',
|
||||
}).format(date)
|
||||
}).format(date);
|
||||
|
||||
return {
|
||||
weekday: weekday.replace('.', ''),
|
||||
dayMonth,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function extractMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof ApiClientError) {
|
||||
return error.message || fallback
|
||||
return error.message || fallback;
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
const navigate = useNavigate()
|
||||
const today = useMemo(() => new Date(), [])
|
||||
const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today])
|
||||
const [windowStartOffset, setWindowStartOffset] = useState(0)
|
||||
const [selectedSportId, setSelectedSportId] = useState<string | undefined>()
|
||||
const [selectedSlot, setSelectedSlot] = useState<SelectedSlot | null>(null)
|
||||
const [navigationError, setNavigationError] = useState<string | null>(null)
|
||||
const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false)
|
||||
const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState<string | null>(null)
|
||||
const navigate = useNavigate();
|
||||
const today = useMemo(() => new Date(), []);
|
||||
const todayIsoDate = useMemo(() => toIsoDateLocal(today), [today]);
|
||||
const [windowStartOffset, setWindowStartOffset] = useState(0);
|
||||
const [selectedSportId, setSelectedSportId] = useState<string | undefined>();
|
||||
const [selectedSlot, setSelectedSlot] = useState<SelectedSlot | null>(null);
|
||||
const [navigationError, setNavigationError] = useState<string | null>(null);
|
||||
const [hasAutoAdjustedInitialDate, setHasAutoAdjustedInitialDate] = useState(false);
|
||||
const [autoAdjustedDateNotice, setAutoAdjustedDateNotice] = useState<string | null>(null);
|
||||
|
||||
const dayOptions = useMemo(() => {
|
||||
return Array.from({ length: DAYS_STEP }).map((_, index) => {
|
||||
const current = addDays(today, windowStartOffset + index)
|
||||
const current = addDays(today, windowStartOffset + index);
|
||||
return {
|
||||
value: toIsoDateLocal(current),
|
||||
date: current,
|
||||
...formatDayLabel(current),
|
||||
}
|
||||
})
|
||||
}, [today, windowStartOffset])
|
||||
};
|
||||
});
|
||||
}, [today, windowStartOffset]);
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? '')
|
||||
const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (dayOptions.some((option) => option.value === selectedDate)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDate(dayOptions[0]?.value ?? '')
|
||||
}, [dayOptions, selectedDate])
|
||||
setSelectedDate(dayOptions[0]?.value ?? '');
|
||||
}, [dayOptions, selectedDate]);
|
||||
|
||||
const availabilityQuery = useQuery({
|
||||
queryKey: ['public-booking-availability', complexSlug, selectedDate, selectedSportId],
|
||||
@@ -125,26 +125,23 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
date: selectedDate,
|
||||
...(selectedSportId ? { sportId: selectedSportId } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const availability = availabilityQuery.data
|
||||
if (!availability) return
|
||||
const availability = availabilityQuery.data;
|
||||
if (!availability) return;
|
||||
|
||||
if (!availability.sportSelectionRequired) {
|
||||
if (!selectedSportId && availability.sports[0]) {
|
||||
setSelectedSportId(availability.sports[0].id)
|
||||
setSelectedSportId(availability.sports[0].id);
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selectedSportId &&
|
||||
!availability.sports.some((sport) => sport.id === selectedSportId)
|
||||
) {
|
||||
setSelectedSportId(undefined)
|
||||
if (selectedSportId && !availability.sports.some((sport) => sport.id === selectedSportId)) {
|
||||
setSelectedSportId(undefined);
|
||||
}
|
||||
}, [availabilityQuery.data, selectedSportId])
|
||||
}, [availabilityQuery.data, selectedSportId]);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -158,12 +155,12 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
customerName: '',
|
||||
customerPhone: '',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const createBookingMutation = useMutation({
|
||||
mutationFn: (payload: BookingFormValues) => {
|
||||
if (!selectedSlot) {
|
||||
throw new Error('Debes seleccionar un horario.')
|
||||
throw new Error('Debes seleccionar un horario.');
|
||||
}
|
||||
|
||||
return apiClient.publicBookings.create(complexSlug, {
|
||||
@@ -173,123 +170,125 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
startTime: selectedSlot.startTime,
|
||||
customerName: payload.customerName,
|
||||
customerPhone: payload.customerPhone,
|
||||
})
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const onSubmit = async (values: BookingFormValues) => {
|
||||
setNavigationError(null)
|
||||
const booking = await createBookingMutation.mutateAsync(values)
|
||||
setNavigationError(null);
|
||||
const booking = await createBookingMutation.mutateAsync(values);
|
||||
|
||||
if (!booking.bookingCode) {
|
||||
setNavigationError('No se pudo obtener el codigo de confirmacion de la reserva.')
|
||||
return
|
||||
setNavigationError('No se pudo obtener el codigo de confirmacion de la reserva.');
|
||||
return;
|
||||
}
|
||||
|
||||
reset()
|
||||
setSelectedSlot(null)
|
||||
await availabilityQuery.refetch()
|
||||
reset();
|
||||
setSelectedSlot(null);
|
||||
await availabilityQuery.refetch();
|
||||
await navigate({
|
||||
to: '/$complexSlug/booking/confirmed/$bookingCode',
|
||||
params: {
|
||||
complexSlug,
|
||||
bookingCode: booking.bookingCode,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const visibleCourts = useMemo(() => {
|
||||
const courts = availabilityQuery.data?.courts ?? []
|
||||
const courts = availabilityQuery.data?.courts ?? [];
|
||||
|
||||
if (selectedDate !== todayIsoDate) {
|
||||
return courts
|
||||
return courts;
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes()
|
||||
const now = new Date();
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
return courts
|
||||
.map((court) => ({
|
||||
...court,
|
||||
availableSlots: court.availableSlots.filter(
|
||||
(slot) => toMinutes(slot.startTime) >= nowMinutes,
|
||||
(slot) => toMinutes(slot.startTime) >= nowMinutes
|
||||
),
|
||||
}))
|
||||
.filter((court) => court.availableSlots.length > 0)
|
||||
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate])
|
||||
.filter((court) => court.availableSlots.length > 0);
|
||||
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSlot) return
|
||||
if (!selectedSlot) return;
|
||||
|
||||
const stillAvailable = visibleCourts.some(
|
||||
(court) =>
|
||||
court.courtId === selectedSlot.courtId &&
|
||||
court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime),
|
||||
)
|
||||
court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime)
|
||||
);
|
||||
|
||||
if (!stillAvailable) {
|
||||
setSelectedSlot(null)
|
||||
setSelectedSlot(null);
|
||||
}
|
||||
}, [selectedSlot, visibleCourts])
|
||||
}, [selectedSlot, visibleCourts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAutoAdjustedInitialDate) return
|
||||
if (hasAutoAdjustedInitialDate) return;
|
||||
if (selectedDate !== todayIsoDate) {
|
||||
setHasAutoAdjustedInitialDate(true)
|
||||
return
|
||||
setHasAutoAdjustedInitialDate(true);
|
||||
return;
|
||||
}
|
||||
if (availabilityQuery.isLoading || availabilityQuery.isError || !availabilityQuery.data) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (availabilityQuery.data.sportSelectionRequired && !selectedSportId) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleCourts.length > 0) {
|
||||
setHasAutoAdjustedInitialDate(true)
|
||||
return
|
||||
setHasAutoAdjustedInitialDate(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let cancelled = false;
|
||||
|
||||
const findNextAvailableDate = async () => {
|
||||
for (let dayOffset = 1; dayOffset <= 30; dayOffset += 1) {
|
||||
const candidateDate = toIsoDateLocal(addDays(today, dayOffset))
|
||||
const candidateDate = toIsoDateLocal(addDays(today, dayOffset));
|
||||
|
||||
try {
|
||||
const availability = await apiClient.publicBookings.getAvailability(complexSlug, {
|
||||
date: candidateDate,
|
||||
...(selectedSportId ? { sportId: selectedSportId } : {}),
|
||||
})
|
||||
});
|
||||
|
||||
if (availability.courts.length === 0) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
if (cancelled) return;
|
||||
|
||||
setWindowStartOffset(dayOffset)
|
||||
setSelectedDate(candidateDate)
|
||||
setSelectedSlot(null)
|
||||
setAutoAdjustedDateNotice('No habia turnos disponibles para hoy. Te mostramos el proximo dia con turnos.')
|
||||
setHasAutoAdjustedInitialDate(true)
|
||||
return
|
||||
setWindowStartOffset(dayOffset);
|
||||
setSelectedDate(candidateDate);
|
||||
setSelectedSlot(null);
|
||||
setAutoAdjustedDateNotice(
|
||||
'No habia turnos disponibles para hoy. Te mostramos el proximo dia con turnos.'
|
||||
);
|
||||
setHasAutoAdjustedInitialDate(true);
|
||||
return;
|
||||
} catch {
|
||||
break
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setHasAutoAdjustedInitialDate(true)
|
||||
setHasAutoAdjustedInitialDate(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void findNextAvailableDate()
|
||||
void findNextAvailableDate();
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
availabilityQuery.data,
|
||||
availabilityQuery.isError,
|
||||
@@ -301,33 +300,33 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
today,
|
||||
todayIsoDate,
|
||||
visibleCourts.length,
|
||||
])
|
||||
]);
|
||||
|
||||
const canGoBack = windowStartOffset > 0
|
||||
const canGoBack = windowStartOffset > 0;
|
||||
|
||||
const goToPreviousFiveDays = () => {
|
||||
if (!canGoBack) return
|
||||
if (!canGoBack) return;
|
||||
|
||||
setWindowStartOffset((previous) => {
|
||||
const next = Math.max(0, previous - DAYS_STEP)
|
||||
const nextStartDate = toIsoDateLocal(addDays(today, next))
|
||||
setSelectedDate(nextStartDate)
|
||||
setSelectedSlot(null)
|
||||
setAutoAdjustedDateNotice(null)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const next = Math.max(0, previous - DAYS_STEP);
|
||||
const nextStartDate = toIsoDateLocal(addDays(today, next));
|
||||
setSelectedDate(nextStartDate);
|
||||
setSelectedSlot(null);
|
||||
setAutoAdjustedDateNotice(null);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const goToNextFiveDays = () => {
|
||||
setWindowStartOffset((previous) => {
|
||||
const next = previous + DAYS_STEP
|
||||
const nextStartDate = toIsoDateLocal(addDays(today, next))
|
||||
setSelectedDate(nextStartDate)
|
||||
setSelectedSlot(null)
|
||||
setAutoAdjustedDateNotice(null)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const next = previous + DAYS_STEP;
|
||||
const nextStartDate = toIsoDateLocal(addDays(today, next));
|
||||
setSelectedDate(nextStartDate);
|
||||
setSelectedSlot(null);
|
||||
setAutoAdjustedDateNotice(null);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-linear-to-b from-background to-muted/40">
|
||||
@@ -372,9 +371,9 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedDate(option.value)
|
||||
setSelectedSlot(null)
|
||||
setAutoAdjustedDateNotice(null)
|
||||
setSelectedDate(option.value);
|
||||
setSelectedSlot(null);
|
||||
setAutoAdjustedDateNotice(null);
|
||||
}}
|
||||
className={`rounded-xl border px-1 py-2 text-center transition-colors sm:px-2 ${
|
||||
selectedDate === option.value
|
||||
@@ -416,9 +415,9 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
<Select
|
||||
value={selectedSportId ?? ''}
|
||||
onValueChange={(value) => {
|
||||
setSelectedSportId(value)
|
||||
setSelectedSlot(null)
|
||||
setAutoAdjustedDateNotice(null)
|
||||
setSelectedSportId(value);
|
||||
setSelectedSlot(null);
|
||||
setAutoAdjustedDateNotice(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 w-full">
|
||||
@@ -479,7 +478,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
{court.availableSlots.map((slot) => {
|
||||
const isSelected =
|
||||
selectedSlot?.courtId === court.courtId &&
|
||||
selectedSlot.startTime === slot.startTime
|
||||
selectedSlot.startTime === slot.startTime;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -493,7 +492,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
sportName: court.sport.name,
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
})
|
||||
});
|
||||
}}
|
||||
className={`h-10 rounded-lg border text-sm transition-colors ${
|
||||
isSelected
|
||||
@@ -503,7 +502,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
>
|
||||
{slot.startTime}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
@@ -548,9 +547,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}
|
||||
</p>
|
||||
)}
|
||||
{navigationError && (
|
||||
<p className="text-sm text-destructive">{navigationError}</p>
|
||||
)}
|
||||
{navigationError && <p className="text-sm text-destructive">{navigationError}</p>}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
|
||||
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
|
||||
@@ -560,5 +557,5 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, type ErrorComponentProps } from '@tanstack/react-router'
|
||||
import { AlertTriangle, Home, RefreshCw, Route } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { type ErrorComponentProps, Link } from '@tanstack/react-router';
|
||||
import { AlertTriangle, Home, RefreshCw, Route } from 'lucide-react';
|
||||
|
||||
export function ServerErrorPage({ error, reset }: ErrorComponentProps) {
|
||||
return (
|
||||
@@ -47,5 +47,5 @@ export function ServerErrorPage({ error, reset }: ErrorComponentProps) {
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,125 +6,125 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: "Geist Variable", sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import type {
|
||||
AdminBooking,
|
||||
CreateAdminBookingInput,
|
||||
Court,
|
||||
CreateCourtInput,
|
||||
CreatePublicBookingInput,
|
||||
Complex,
|
||||
Court,
|
||||
CreateAdminBookingInput,
|
||||
CreateComplexPayload,
|
||||
CreateComplexResponse,
|
||||
CreateCourtInput,
|
||||
CreatePublicBookingInput,
|
||||
CreateSportInput,
|
||||
OnboardingCompleteInput,
|
||||
OnboardingCompleteResponse,
|
||||
@@ -23,48 +22,49 @@ import type {
|
||||
PublicBookingConfirmation,
|
||||
Sport,
|
||||
UpdateAdminBookingStatusInput,
|
||||
UpdateCourtInput,
|
||||
UpdateComplexPayload,
|
||||
UpdateComplexResponse,
|
||||
UpdateCourtInput,
|
||||
UpdateSportInput,
|
||||
UserProfileResponse,
|
||||
} from '@repo/api-contract'
|
||||
} from '@repo/api-contract';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
const apiBaseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL?.trim() ||
|
||||
(import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin)
|
||||
let accessToken: string | null = null
|
||||
(import.meta.env.DEV ? 'http://localhost:3000' : window.location.origin);
|
||||
let accessToken: string | null = null;
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status?: number
|
||||
details?: unknown
|
||||
causeError?: unknown
|
||||
status?: number;
|
||||
details?: unknown;
|
||||
causeError?: unknown;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options?: { status?: number; details?: unknown; causeError?: unknown },
|
||||
options?: { status?: number; details?: unknown; causeError?: unknown }
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiClientError'
|
||||
this.status = options?.status
|
||||
this.details = options?.details
|
||||
this.causeError = options?.causeError
|
||||
super(message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = options?.status;
|
||||
this.details = options?.details;
|
||||
this.causeError = options?.causeError;
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorHandlers = {
|
||||
onForbidden?: (error: ApiClientError) => void | Promise<void>
|
||||
onError?: (error: ApiClientError) => void | Promise<void>
|
||||
}
|
||||
onForbidden?: (error: ApiClientError) => void | Promise<void>;
|
||||
onError?: (error: ApiClientError) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const handlers: ErrorHandlers = {}
|
||||
const handlers: ErrorHandlers = {};
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
function extractMessage(details: unknown, fallback: string) {
|
||||
if (
|
||||
@@ -73,239 +73,221 @@ function extractMessage(details: unknown, fallback: string) {
|
||||
'message' in details &&
|
||||
typeof details.message === 'string'
|
||||
) {
|
||||
return details.message
|
||||
return details.message;
|
||||
}
|
||||
|
||||
return fallback
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ApiClientError {
|
||||
if (error instanceof ApiClientError) return error
|
||||
if (error instanceof ApiClientError) return error;
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
const details = error.response?.data
|
||||
const status = error.response?.status;
|
||||
const details = error.response?.data;
|
||||
|
||||
return new ApiClientError(
|
||||
extractMessage(details, error.message || 'Request failed'),
|
||||
{
|
||||
status,
|
||||
details,
|
||||
causeError: error,
|
||||
},
|
||||
)
|
||||
return new ApiClientError(extractMessage(details, error.message || 'Request failed'), {
|
||||
status,
|
||||
details,
|
||||
causeError: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new ApiClientError(error.message, {
|
||||
causeError: error,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return new ApiClientError('Error inesperado.', {
|
||||
causeError: error,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
if (!accessToken) return config
|
||||
if (!accessToken) return config;
|
||||
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${accessToken}`
|
||||
return config
|
||||
})
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const normalized = normalizeError(error)
|
||||
const normalized = normalizeError(error);
|
||||
|
||||
if (normalized.status === 403 && handlers.onForbidden) {
|
||||
await handlers.onForbidden(normalized)
|
||||
await handlers.onForbidden(normalized);
|
||||
}
|
||||
|
||||
if (handlers.onError) {
|
||||
await handlers.onError(normalized)
|
||||
await handlers.onError(normalized);
|
||||
}
|
||||
|
||||
return Promise.reject(normalized)
|
||||
},
|
||||
)
|
||||
return Promise.reject(normalized);
|
||||
}
|
||||
);
|
||||
|
||||
export const apiClient = {
|
||||
onboarding: {
|
||||
start: async (payload: OnboardingStartInput) => {
|
||||
const response = await http.post<OnboardingStartResponse>(
|
||||
'/api/onboarding/start',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
const response = await http.post<OnboardingStartResponse>('/api/onboarding/start', payload);
|
||||
return response.data;
|
||||
},
|
||||
verifyOtp: async (payload: OnboardingVerifyOtpInput) => {
|
||||
const response = await http.post<OnboardingVerifyOtpResponse>(
|
||||
'/api/onboarding/verify-otp',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
resendOtp: async (payload: OnboardingResendOtpInput) => {
|
||||
const response = await http.post<OnboardingResendOtpResponse>(
|
||||
'/api/onboarding/resend-otp',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
complete: async (payload: OnboardingCompleteInput) => {
|
||||
const response = await http.post<OnboardingCompleteResponse>(
|
||||
'/api/onboarding/complete',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
plans: {
|
||||
list: async () => {
|
||||
const response = await http.get<PlanSummary[]>('/api/plans')
|
||||
return response.data
|
||||
const response = await http.get<PlanSummary[]>('/api/plans');
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
user: {
|
||||
getProfile: async () => {
|
||||
const response = await http.get<UserProfileResponse>('/api/user/profile')
|
||||
return response.data
|
||||
const response = await http.get<UserProfileResponse>('/api/user/profile');
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
complexes: {
|
||||
create: async (payload: CreateComplexPayload) => {
|
||||
const response = await http.post<CreateComplexResponse>(
|
||||
'/api/complexes',
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
const response = await http.post<CreateComplexResponse>('/api/complexes', payload);
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id: string) => {
|
||||
const response = await http.get<Complex>(`/api/complexes/${id}`)
|
||||
return response.data
|
||||
const response = await http.get<Complex>(`/api/complexes/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
getBySlug: async (slug: string) => {
|
||||
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`)
|
||||
return response.data
|
||||
const response = await http.get<Complex>(`/api/complexes/slug/${slug}`);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, payload: UpdateComplexPayload) => {
|
||||
const response = await http.patch<UpdateComplexResponse>(
|
||||
`/api/complexes/${id}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
const response = await http.patch<UpdateComplexResponse>(`/api/complexes/${id}`, payload);
|
||||
return response.data;
|
||||
},
|
||||
listMine: async () => {
|
||||
const response = await http.get<Complex[]>('/api/complexes/mine')
|
||||
return response.data
|
||||
const response = await http.get<Complex[]>('/api/complexes/mine');
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
sports: {
|
||||
list: async () => {
|
||||
const response = await http.get<Sport[]>('/api/sports')
|
||||
return response.data
|
||||
const response = await http.get<Sport[]>('/api/sports');
|
||||
return response.data;
|
||||
},
|
||||
create: async (payload: CreateSportInput) => {
|
||||
const response = await http.post<Sport>('/api/sports', payload)
|
||||
return response.data
|
||||
const response = await http.post<Sport>('/api/sports', payload);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, payload: UpdateSportInput) => {
|
||||
const response = await http.patch<Sport>(`/api/sports/${id}`, payload)
|
||||
return response.data
|
||||
const response = await http.patch<Sport>(`/api/sports/${id}`, payload);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
courts: {
|
||||
listByComplex: async (complexId: string) => {
|
||||
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`)
|
||||
return response.data
|
||||
const response = await http.get<Court[]>(`/api/courts/complex/${complexId}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (complexId: string, payload: CreateCourtInput) => {
|
||||
const response = await http.post<Court>(
|
||||
`/api/courts/complex/${complexId}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
const response = await http.post<Court>(`/api/courts/complex/${complexId}`, payload);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id: string, payload: UpdateCourtInput) => {
|
||||
const response = await http.patch<Court>(`/api/courts/${id}`, payload)
|
||||
return response.data
|
||||
const response = await http.patch<Court>(`/api/courts/${id}`, payload);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
publicBookings: {
|
||||
getAvailability: async (
|
||||
complexSlug: string,
|
||||
params: {
|
||||
date: string
|
||||
sportId?: string
|
||||
},
|
||||
date: string;
|
||||
sportId?: string;
|
||||
}
|
||||
) => {
|
||||
const response = await http.get<PublicAvailabilityResponse>(
|
||||
`/api/public-bookings/complex/${complexSlug}/availability`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
create: async (complexSlug: string, payload: CreatePublicBookingInput) => {
|
||||
const response = await http.post<PublicBooking>(
|
||||
`/api/public-bookings/complex/${complexSlug}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
getConfirmation: async (complexSlug: string, bookingCode: string) => {
|
||||
const response = await http.get<PublicBookingConfirmation>(
|
||||
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`,
|
||||
)
|
||||
return response.data
|
||||
`/api/public-bookings/complex/${complexSlug}/confirmation/${bookingCode}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
adminBookings: {
|
||||
listByComplex: async (
|
||||
complexId: string,
|
||||
params: {
|
||||
fromDate: string
|
||||
},
|
||||
fromDate: string;
|
||||
}
|
||||
) => {
|
||||
const response = await http.get<{ bookings: AdminBooking[] }>(
|
||||
`/api/admin-bookings/complex/${complexId}`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
create: async (complexId: string, payload: CreateAdminBookingInput) => {
|
||||
const response = await http.post<AdminBooking>(
|
||||
`/api/admin-bookings/complex/${complexId}`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
updateStatus: async (
|
||||
bookingId: string,
|
||||
payload: UpdateAdminBookingStatusInput,
|
||||
) => {
|
||||
updateStatus: async (bookingId: string, payload: UpdateAdminBookingStatusInput) => {
|
||||
const response = await http.patch<AdminBooking>(
|
||||
`/api/admin-bookings/${bookingId}/status`,
|
||||
payload,
|
||||
)
|
||||
return response.data
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export type ApiClient = typeof apiClient
|
||||
export type ApiClient = typeof apiClient;
|
||||
|
||||
export function configureApiClient(nextHandlers: ErrorHandlers) {
|
||||
handlers.onForbidden = nextHandlers.onForbidden
|
||||
handlers.onError = nextHandlers.onError
|
||||
handlers.onForbidden = nextHandlers.onForbidden;
|
||||
handlers.onError = nextHandlers.onError;
|
||||
}
|
||||
|
||||
export function setApiAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
@@ -1,119 +1,117 @@
|
||||
import { setApiAccessToken } from '@/lib/api-client';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react'
|
||||
import type { Session, User } from '@supabase/supabase-js'
|
||||
import { setApiAccessToken } from '@/lib/api-client'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
} from 'react';
|
||||
|
||||
type SignInParams = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type SignUpParams = {
|
||||
email: string
|
||||
password: string
|
||||
fullName: string
|
||||
}
|
||||
email: string;
|
||||
password: string;
|
||||
fullName: string;
|
||||
};
|
||||
|
||||
type UpdateProfileParams = {
|
||||
fullName: string
|
||||
}
|
||||
fullName: string;
|
||||
};
|
||||
|
||||
type UpdatePasswordParams = {
|
||||
password: string
|
||||
}
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: User | null
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
isAuthenticated: boolean
|
||||
displayName: string
|
||||
initials: string
|
||||
avatarUrl: string | null
|
||||
signInWithPassword: (params: SignInParams) => Promise<void>
|
||||
signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }>
|
||||
updateProfile: (params: UpdateProfileParams) => Promise<void>
|
||||
updatePassword: (params: UpdatePasswordParams) => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
displayName: string;
|
||||
initials: string;
|
||||
avatarUrl: string | null;
|
||||
signInWithPassword: (params: SignInParams) => Promise<void>;
|
||||
signUp: (params: SignUpParams) => Promise<{ requiresEmailConfirmation: boolean }>;
|
||||
updateProfile: (params: UpdateProfileParams) => Promise<void>;
|
||||
updatePassword: (params: UpdatePasswordParams) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function getDisplayName(user: User | null): string {
|
||||
if (!user) return 'Usuario'
|
||||
if (!user) return 'Usuario';
|
||||
|
||||
const fromMetadata =
|
||||
user.user_metadata?.name ??
|
||||
user.user_metadata?.full_name ??
|
||||
user.user_metadata?.user_name
|
||||
user.user_metadata?.name ?? user.user_metadata?.full_name ?? user.user_metadata?.user_name;
|
||||
|
||||
if (typeof fromMetadata === 'string' && fromMetadata.trim().length > 0) {
|
||||
return fromMetadata.trim()
|
||||
return fromMetadata.trim();
|
||||
}
|
||||
|
||||
return user.email ?? 'Usuario'
|
||||
return user.email ?? 'Usuario';
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/).slice(0, 2)
|
||||
const initials = words.map((word) => word[0]?.toUpperCase() ?? '').join('')
|
||||
return initials || 'U'
|
||||
const words = name.trim().split(/\s+/).slice(0, 2);
|
||||
const initials = words.map((word) => word[0]?.toUpperCase() ?? '').join('');
|
||||
return initials || 'U';
|
||||
}
|
||||
|
||||
function getAvatarUrl(user: User | null): string | null {
|
||||
if (!user) return null
|
||||
if (!user) return null;
|
||||
|
||||
const value = user.user_metadata?.avatar_url ?? user.user_metadata?.picture
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
const value = user.user_metadata?.avatar_url ?? user.user_metadata?.picture;
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
let mounted = true;
|
||||
|
||||
const init = async () => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
const { data } = await supabase.auth.getSession();
|
||||
|
||||
if (!mounted) return
|
||||
if (!mounted) return;
|
||||
|
||||
setSession(data.session)
|
||||
setUser(data.session?.user ?? null)
|
||||
setApiAccessToken(data.session?.access_token ?? null)
|
||||
setLoading(false)
|
||||
}
|
||||
setSession(data.session);
|
||||
setUser(data.session?.user ?? null);
|
||||
setApiAccessToken(data.session?.access_token ?? null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
void init()
|
||||
void init();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession)
|
||||
setUser(nextSession?.user ?? null)
|
||||
setApiAccessToken(nextSession?.access_token ?? null)
|
||||
setLoading(false)
|
||||
})
|
||||
setSession(nextSession);
|
||||
setUser(nextSession?.user ?? null);
|
||||
setApiAccessToken(nextSession?.access_token ?? null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(() => {
|
||||
const displayName = getDisplayName(user)
|
||||
const initials = getInitials(displayName)
|
||||
const avatarUrl = getAvatarUrl(user)
|
||||
const displayName = getDisplayName(user);
|
||||
const initials = getInitials(displayName);
|
||||
const avatarUrl = getAvatarUrl(user);
|
||||
|
||||
return {
|
||||
user,
|
||||
@@ -127,10 +125,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
signUp: async ({ email, password, fullName }) => {
|
||||
@@ -143,15 +141,15 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
name: fullName,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
requiresEmailConfirmation: !data.session,
|
||||
}
|
||||
};
|
||||
},
|
||||
updateProfile: async ({ fullName }) => {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
@@ -159,39 +157,39 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
full_name: fullName,
|
||||
name: fullName,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
updatePassword: async ({ password }) => {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password,
|
||||
})
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
signOut: async () => {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) {
|
||||
throw error
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [loading, session, user])
|
||||
};
|
||||
}, [loading, session, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug'
|
||||
const CURRENT_COMPLEX_SLUG_KEY = 'current-complex-slug';
|
||||
|
||||
export function getCurrentComplexSlug(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(CURRENT_COMPLEX_SLUG_KEY)
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(CURRENT_COMPLEX_SLUG_KEY);
|
||||
}
|
||||
|
||||
export function setCurrentComplexSlug(complexSlug: string) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug)
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(CURRENT_COMPLEX_SLUG_KEY, complexSlug);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -8,4 +8,4 @@ export const queryClient = new QueryClient({
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
'Missing Supabase environment variables. Define VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.',
|
||||
)
|
||||
'Missing Supabase environment variables. Define VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY.'
|
||||
);
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
@@ -15,4 +15,4 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
persistSession: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,68 +1,63 @@
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react'
|
||||
} from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
type ResolvedTheme = 'light' | 'dark'
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
theme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const THEME_STORAGE_KEY = 'theme'
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
const THEME_STORAGE_KEY = 'theme';
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyResolvedTheme(theme: ResolvedTheme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: PropsWithChildren) {
|
||||
const [theme, setThemeState] = useState<Theme>('system')
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')
|
||||
const [theme, setThemeState] = useState<Theme>('system');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const initialTheme: Theme =
|
||||
saved === 'light' || saved === 'dark' || saved === 'system'
|
||||
? saved
|
||||
: 'system'
|
||||
saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
|
||||
|
||||
setThemeState(initialTheme)
|
||||
}, [])
|
||||
setThemeState(initialTheme);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const updateTheme = () => {
|
||||
const nextResolvedTheme =
|
||||
theme === 'system' ? getSystemTheme() : theme
|
||||
setResolvedTheme(nextResolvedTheme)
|
||||
applyResolvedTheme(nextResolvedTheme)
|
||||
}
|
||||
const nextResolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
setResolvedTheme(nextResolvedTheme);
|
||||
applyResolvedTheme(nextResolvedTheme);
|
||||
};
|
||||
|
||||
updateTheme()
|
||||
updateTheme();
|
||||
|
||||
mediaQuery.addEventListener('change', updateTheme)
|
||||
return () => mediaQuery.removeEventListener('change', updateTheme)
|
||||
}, [theme])
|
||||
mediaQuery.addEventListener('change', updateTheme);
|
||||
return () => mediaQuery.removeEventListener('change', updateTheme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (nextTheme: Theme) => {
|
||||
setThemeState(nextTheme)
|
||||
localStorage.setItem(THEME_STORAGE_KEY, nextTheme)
|
||||
}
|
||||
setThemeState(nextTheme);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
|
||||
};
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
@@ -70,20 +65,20 @@ export function ThemeProvider({ children }: PropsWithChildren) {
|
||||
resolvedTheme,
|
||||
setTheme,
|
||||
}),
|
||||
[theme, resolvedTheme],
|
||||
)
|
||||
[theme, resolvedTheme]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
const context = useContext(ThemeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider')
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
export type { Theme }
|
||||
export type { Theme };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { StrictMode, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
import { AuthProvider, useAuth } from '@/lib/auth'
|
||||
import { apiClient, configureApiClient } from '@/lib/api-client'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { ThemeProvider } from '@/lib/theme'
|
||||
import { router } from './router'
|
||||
import './index.css'
|
||||
import { apiClient, configureApiClient } from '@/lib/api-client';
|
||||
import { AuthProvider, useAuth } from '@/lib/auth';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { ThemeProvider } from '@/lib/theme';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { router } from './router';
|
||||
import './index.css';
|
||||
|
||||
function AppRouter() {
|
||||
const auth = useAuth()
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
router.invalidate()
|
||||
}, [auth.isAuthenticated, auth.loading, auth.user])
|
||||
router.invalidate();
|
||||
}, [auth.isAuthenticated, auth.loading, auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
configureApiClient({
|
||||
onForbidden: async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await auth.signOut()
|
||||
await router.navigate({ to: '/login' })
|
||||
await auth.signOut();
|
||||
await router.navigate({ to: '/login' });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[API]', error)
|
||||
console.error('[API]', error);
|
||||
},
|
||||
})
|
||||
}, [auth])
|
||||
});
|
||||
}, [auth]);
|
||||
|
||||
if (auth.loading) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6">
|
||||
<p className="text-sm text-muted-foreground">Validando sesión...</p>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ function AppRouter() {
|
||||
<RouterProvider router={router} context={{ auth, api: apiClient }} />
|
||||
{import.meta.env.DEV && <TanStackRouterDevtools router={router} />}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
@@ -60,5 +60,5 @@ createRoot(document.getElementById('root')!).render(
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { createRouter } from '@tanstack/react-router';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
@@ -8,10 +8,10 @@ export const router = createRouter({
|
||||
auth: undefined!,
|
||||
api: apiClient,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, createFileRoute } from '@tanstack/react-router'
|
||||
import { Outlet, createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/$complexSlug/booking')({
|
||||
component: Outlet,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PublicBookingConfirmationPage } from '@/features/public-booking/public-booking-confirmation-page'
|
||||
import { PublicBookingConfirmationPage } from '@/features/public-booking/public-booking-confirmation-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/$complexSlug/booking/confirmed/$bookingCode')({
|
||||
component: PublicBookingConfirmationRouteComponent,
|
||||
})
|
||||
});
|
||||
|
||||
function PublicBookingConfirmationRouteComponent() {
|
||||
const { complexSlug, bookingCode } = Route.useParams()
|
||||
const { complexSlug, bookingCode } = Route.useParams();
|
||||
|
||||
return (
|
||||
<PublicBookingConfirmationPage
|
||||
complexSlug={complexSlug}
|
||||
bookingCode={bookingCode}
|
||||
/>
|
||||
)
|
||||
return <PublicBookingConfirmationPage complexSlug={complexSlug} bookingCode={bookingCode} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PublicBookingPage } from '@/features/public-booking/public-booking-page'
|
||||
import { PublicBookingPage } from '@/features/public-booking/public-booking-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/$complexSlug/booking/')({
|
||||
component: PublicBookingIndexRouteComponent,
|
||||
})
|
||||
});
|
||||
|
||||
function PublicBookingIndexRouteComponent() {
|
||||
const { complexSlug } = Route.useParams()
|
||||
const { complexSlug } = Route.useParams();
|
||||
|
||||
return <PublicBookingPage complexSlug={complexSlug} />
|
||||
return <PublicBookingPage complexSlug={complexSlug} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||
import type { AuthContextValue } from '@/lib/auth'
|
||||
import type { ApiClient } from '@/lib/api-client'
|
||||
import { NotFoundPage } from '@/features/not-found/not-found-page'
|
||||
import { ServerErrorPage } from '@/features/server-error/server-error-page'
|
||||
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';
|
||||
|
||||
type RouterContext = {
|
||||
auth: AuthContextValue
|
||||
api: ApiClient
|
||||
}
|
||||
auth: AuthContextValue;
|
||||
api: ApiClient;
|
||||
};
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: Outlet,
|
||||
errorComponent: ServerErrorPage,
|
||||
notFoundComponent: NotFoundPage,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ComplexSettingsPage } from '@/features/complex/complex-settings-page'
|
||||
import { ComplexSettingsPage } from '@/features/complex/complex-settings-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/complex/$slug/edit')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { slug } = Route.useParams()
|
||||
return <ComplexSettingsPage complexSlug={slug} />
|
||||
const { slug } = Route.useParams();
|
||||
return <ComplexSettingsPage complexSlug={slug} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { HomePage } from '@/features/home/home-page'
|
||||
import { HomePage } from '@/features/home/home-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/')({
|
||||
component: HomePage,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated')({
|
||||
beforeLoad: ({ context, location }) => {
|
||||
@@ -6,7 +6,7 @@ export const Route = createFileRoute('/_app/_authenticated')({
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { AboutPage } from '@/features/about/about-page'
|
||||
import { AboutPage } from '@/features/about/about-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/about')({
|
||||
component: AboutPage,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ProfilePage } from '@/features/profile/profile-page'
|
||||
import { ProfilePage } from '@/features/profile/profile-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/profile')({
|
||||
component: ProfilePage,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { RootLayout } from '@/features/layout/root-layout'
|
||||
import { RootLayout } from '@/features/layout/root-layout';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app')({
|
||||
component: RootLayout,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { z } from 'zod'
|
||||
import { LoginPage } from '@/features/login/login-page'
|
||||
import { LoginPage } from '@/features/login/login-page';
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const loginSearchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
})
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: loginSearchSchema,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (context.auth.isAuthenticated) {
|
||||
throw redirect({ to: '/' })
|
||||
throw redirect({ to: '/' });
|
||||
}
|
||||
},
|
||||
component: LoginRouteComponent,
|
||||
})
|
||||
});
|
||||
|
||||
function LoginRouteComponent() {
|
||||
const search = Route.useSearch()
|
||||
return <LoginPage redirectTo={search.redirect} />
|
||||
const search = Route.useSearch();
|
||||
return <LoginPage redirectTo={search.redirect} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { OnboardPage } from '@/features/onboard/onboard-page'
|
||||
import { OnboardPage } from '@/features/onboard/onboard-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/onboard')({
|
||||
component: OnboardPage,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -6,8 +6,5 @@
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
import path from 'node:path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -17,29 +17,26 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
|
||||
return 'react-vendor'
|
||||
return 'react-vendor';
|
||||
}
|
||||
if (id.includes('node_modules/@tanstack/react-router')) {
|
||||
return 'router-vendor'
|
||||
return 'router-vendor';
|
||||
}
|
||||
if (id.includes('node_modules/@supabase/supabase-js')) {
|
||||
return 'supabase-vendor'
|
||||
return 'supabase-vendor';
|
||||
}
|
||||
if (
|
||||
id.includes('node_modules/radix-ui') ||
|
||||
id.includes('node_modules/lucide-react')
|
||||
) {
|
||||
return 'ui-vendor'
|
||||
if (id.includes('node_modules/radix-ui') || id.includes('node_modules/lucide-react')) {
|
||||
return 'ui-vendor';
|
||||
}
|
||||
if (
|
||||
id.includes('node_modules/react-hook-form') ||
|
||||
id.includes('node_modules/@hookform/resolvers') ||
|
||||
id.includes('node_modules/zod')
|
||||
) {
|
||||
return 'form-vendor'
|
||||
return 'form-vendor';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user