Initial commit

This commit is contained in:
Jose Selesan
2026-04-08 22:53:11 -03:00
commit 9ae270609d
179 changed files with 28096 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
export function AboutPage() {
return (
<main className="mx-auto flex min-h-[60vh] 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">
<h2 className="text-2xl font-semibold">About</h2>
<p className="mt-2 text-sm text-muted-foreground">
Ruta de ejemplo funcionando con TanStack Router.
</p>
</section>
</main>
)
}

View File

@@ -0,0 +1,547 @@
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,
type DayOfWeek,
} from '@repo/api-contract'
import { Controller, useFieldArray, useForm } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ApiClientError, apiClient } from '@/lib/api-client'
import { setCurrentComplexSlug } from '@/lib/current-complex'
const DAY_OPTIONS: Array<{ value: DayOfWeek; label: string }> = [
{ value: 'MONDAY', label: 'Lunes' },
{ value: 'TUESDAY', label: 'Martes' },
{ value: 'WEDNESDAY', label: 'Miércoles' },
{ value: 'THURSDAY', label: 'Jueves' },
{ value: 'FRIDAY', label: 'Viernes' },
{ value: 'SATURDAY', label: 'Sábado' },
{ value: 'SUNDAY', label: 'Domingo' },
]
const DAY_ORDER: DayOfWeek[] = DAY_OPTIONS.map((option) => option.value)
const DAY_LABEL_BY_VALUE = new Map(
DAY_OPTIONS.map((option) => [option.value, option.label]),
)
function formatTimeCompact(value: string): string {
const [rawHours = '00', rawMinutes = '00'] = value.split(':')
const hours = Number(rawHours)
return `${hours}:${rawMinutes}`
}
function formatDayRange(dayIndexes: number[]): string {
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]
for (let index = 1; index < sorted.length; index += 1) {
const current = sorted[index]
if (current === previous + 1) {
previous = current
continue
}
chunks.push({ start, end: previous })
start = current
previous = current
}
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
if (!startLabel || !endLabel) return ''
if (chunk.start === chunk.end) return startLabel
return `${startLabel} a ${endLabel}`
})
.filter(Boolean)
.join(', ')
}
function summarizeAvailability(
availability: Array<{
dayOfWeek: DayOfWeek
startTime: string
endTime: string
}>,
) {
const grouped = new Map<string, number[]>()
for (const slot of availability) {
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)
}
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)}`
})
.filter(Boolean)
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('es-AR', {
style: 'currency',
currency: 'ARS',
maximumFractionDigits: 2,
}).format(amount)
}
function createDefaultValues(): CreateCourtInput {
return {
name: '',
sportId: '',
slotDurationMinutes: 60,
basePrice: 0,
availability: [
{
dayOfWeek: 'MONDAY',
startTime: '08:00',
endTime: '22:00',
},
],
}
}
type CourtFormSectionProps = {
complexId: string | null
editingCourt: Court | null
onCancelEdit: () => void
}
function CourtFormSection({
complexId,
editingCourt,
onCancelEdit,
}: CourtFormSectionProps) {
const queryClient = useQueryClient()
const [formError, setFormError] = useState<string | null>(null)
const sportsQuery = useQuery({
queryKey: ['sports'],
queryFn: () => apiClient.sports.list(),
})
const form = useForm<CreateCourtInput>({
resolver: zodResolver(createCourtSchema),
mode: 'onBlur',
defaultValues: createDefaultValues(),
})
const availabilityFieldArray = useFieldArray({
control: form.control,
name: 'availability',
})
const saveMutation = useMutation({
mutationFn: async (payload: CreateCourtInput) => {
if (editingCourt) {
return apiClient.courts.update(editingCourt.id, payload)
}
if (!complexId) {
throw new ApiClientError('No se pudo resolver el complejo para guardar la cancha.')
}
return apiClient.courts.create(complexId, payload)
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['courts', complexId],
})
setFormError(null)
if (editingCourt) {
onCancelEdit()
}
form.reset(createDefaultValues())
},
onError: (error) => {
const message =
error instanceof ApiClientError
? error.message
: 'No se pudo guardar la cancha. Intenta nuevamente.'
setFormError(message)
},
})
const activeSports = useMemo(
() => (sportsQuery.data ?? []).filter((sport) => sport.isActive),
[sportsQuery.data],
)
const onSubmit = async (values: CreateCourtInput) => {
setFormError(null)
await saveMutation.mutateAsync(values)
}
useEffect(() => {
if (!editingCourt) {
form.reset(createDefaultValues())
return
}
form.reset({
name: editingCourt.name,
sportId: editingCourt.sportId,
slotDurationMinutes: editingCourt.slotDurationMinutes,
basePrice: editingCourt.basePrice,
availability: editingCourt.availability.map((slot) => ({
dayOfWeek: slot.dayOfWeek,
startTime: slot.startTime,
endTime: slot.endTime,
})),
})
}, [editingCourt, form])
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">
<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.
</p>
<form
className="mt-4 space-y-4"
onSubmit={form.handleSubmit((values) => {
void onSubmit(values)
})}
>
<Field data-invalid={Boolean(form.formState.errors.name)}>
<FieldLabel htmlFor="court-name">Nombre o descripción</FieldLabel>
<Input
id="court-name"
placeholder="Ej: Futbol 5 - Cancha 1"
aria-invalid={Boolean(form.formState.errors.name)}
{...form.register('name')}
/>
<FieldError errors={[form.formState.errors.name]} />
</Field>
<div className="grid gap-4 md:grid-cols-3">
<Field data-invalid={Boolean(form.formState.errors.sportId)}>
<FieldLabel>Deporte</FieldLabel>
<Controller
control={form.control}
name="sportId"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
disabled={sportsQuery.isLoading}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{activeSports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[form.formState.errors.sportId]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.slotDurationMinutes)}>
<FieldLabel htmlFor="slot-duration">Duración por turno (min)</FieldLabel>
<Input
id="slot-duration"
type="number"
min={15}
step={5}
aria-invalid={Boolean(form.formState.errors.slotDurationMinutes)}
{...form.register('slotDurationMinutes', { valueAsNumber: true })}
/>
<FieldError errors={[form.formState.errors.slotDurationMinutes]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.basePrice)}>
<FieldLabel htmlFor="base-price">Precio base</FieldLabel>
<Input
id="base-price"
type="number"
min={0}
step="0.01"
aria-invalid={Boolean(form.formState.errors.basePrice)}
{...form.register('basePrice', { valueAsNumber: true })}
/>
<FieldError errors={[form.formState.errors.basePrice]} />
</Field>
</div>
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
<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>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
const availability = form.getValues('availability')
const baseStartTime = availability[0]?.startTime ?? '08:00'
const baseEndTime = availability[0]?.endTime ?? '22:00'
const existingDays = new Set(
availability.map((item) => item.dayOfWeek),
)
const missingDays = DAY_OPTIONS.filter(
(option) => !existingDays.has(option.value),
).map((option) => ({
dayOfWeek: option.value,
startTime: baseStartTime,
endTime: baseEndTime,
}))
if (missingDays.length > 0) {
availabilityFieldArray.append(missingDays)
}
}}
>
Agregar semana completa
</Button>
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
availabilityFieldArray.append({
dayOfWeek: 'MONDAY',
startTime: '08:00',
endTime: '22:00',
})
}}
>
Agregar rango
</Button>
</div>
</div>
<div className="space-y-2">
{availabilityFieldArray.fields.map((field, index) => (
<div
key={field.id}
className="grid gap-3 border-b pb-2 md:grid-cols-[1fr_1fr_1fr_auto]"
>
<Field
data-invalid={Boolean(form.formState.errors.availability?.[index]?.dayOfWeek)}
>
<FieldLabel>Día</FieldLabel>
<Controller
control={form.control}
name={`availability.${index}.dayOfWeek`}
render={({ field: dayField }) => (
<Select value={dayField.value} onValueChange={dayField.onChange}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Día" />
</SelectTrigger>
<SelectContent>
{DAY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.dayOfWeek]} />
</Field>
<Field
data-invalid={Boolean(form.formState.errors.availability?.[index]?.startTime)}
>
<FieldLabel>Desde</FieldLabel>
<Input
type="time"
aria-invalid={Boolean(form.formState.errors.availability?.[index]?.startTime)}
{...form.register(`availability.${index}.startTime`)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.startTime]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.availability?.[index]?.endTime)}>
<FieldLabel>Hasta</FieldLabel>
<Input
type="time"
aria-invalid={Boolean(form.formState.errors.availability?.[index]?.endTime)}
{...form.register(`availability.${index}.endTime`)}
/>
<FieldError errors={[form.formState.errors.availability?.[index]?.endTime]} />
</Field>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
className="w-full md:w-auto"
disabled={availabilityFieldArray.fields.length === 1}
onClick={() => {
availabilityFieldArray.remove(index)
}}
>
Quitar
</Button>
</div>
</div>
))}
</div>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<div className="flex flex-wrap items-center gap-2">
<Button
type="submit"
disabled={saveMutation.isPending || sportsQuery.isLoading || !complexId}
>
{saveMutation.isPending ? 'Guardando...' : buttonText}
</Button>
{editingCourt && (
<Button type="button" variant="outline" onClick={onCancelEdit}>
Cancelar edición
</Button>
)}
</div>
</form>
</section>
)
}
type ComplexCourtsPageProps = {
complexSlug: string
}
export function ComplexCourtsPage({ complexSlug }: ComplexCourtsPageProps) {
const [editingCourt, setEditingCourt] = useState<Court | null>(null)
useEffect(() => {
setCurrentComplexSlug(complexSlug)
}, [complexSlug])
const complexQuery = useQuery({
queryKey: ['complex-by-slug', complexSlug],
queryFn: () => apiClient.complexes.getBySlug(complexSlug),
})
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">
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h2 className="text-2xl font-semibold">
{complexQuery.data?.complexName ?? 'Configurar canchas'}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Alta y edición de canchas del complejo.
</p>
</section>
<CourtFormSection
complexId={complexId}
editingCourt={editingCourt}
onCancelEdit={() => {
setEditingCourt(null)
}}
/>
<section className="rounded-xl border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
<h3 className="text-lg font-semibold">Canchas configuradas</h3>
{courtsQuery.isLoading && (
<p className="mt-3 text-sm text-muted-foreground">Cargando canchas...</p>
)}
{courtsQuery.isError && (
<p className="mt-3 text-sm text-destructive">
No se pudieron cargar las canchas del complejo.
</p>
)}
{!courtsQuery.isLoading && (courtsQuery.data?.length ?? 0) === 0 && (
<p className="mt-3 text-sm text-muted-foreground">
Aún no hay canchas. Crea la primera usando el formulario.
</p>
)}
<div className="mt-4 space-y-3">
{(courtsQuery.data ?? []).map((court) => (
<article key={court.id} className="rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div>
<p className="font-medium">{court.name}</p>
<p className="text-sm text-muted-foreground">
{court.sport.name} · {court.slotDurationMinutes} min ·{' '}
{formatCurrency(court.basePrice)}
</p>
</div>
<Button
variant="outline"
className="w-full sm:w-auto"
onClick={() => {
setEditingCourt(court)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
>
Editar
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
{summarizeAvailability(court.availability).map((summary) => (
<span key={`${court.id}-${summary}`} className="rounded-md border px-2 py-1">
{summary}
</span>
))}
</div>
</article>
))}
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,68 @@
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'
const emailSchema = z.object({
email: z.string().email('Ingresa un email válido.'),
})
type EmailForm = z.infer<typeof emailSchema>
export function HomePage() {
const {
register,
handleSubmit,
watch,
formState: { errors, isValid, isSubmitSuccessful },
} = useForm<EmailForm>({
resolver: zodResolver(emailSchema),
mode: 'onChange',
defaultValues: {
email: '',
},
})
const emailValue = watch('email')
const onSubmit = (_data: EmailForm) => {
// Demo form: no-op; success state is managed by react-hook-form.
}
return (
<main className="mx-auto flex min-h-[60vh] 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">
<h1 className="mb-2 text-2xl font-semibold">Frontend listo</h1>
<p className="mb-4 text-sm text-muted-foreground">
React + Vite + TypeScript + shadcn + zod + react-hook-form.
</p>
<form onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<Button type="submit" className="mt-3 w-full" disabled={!isValid}>
Continuar
</Button>
</form>
{isSubmitSuccessful && !errors.email && emailValue && (
<p className="mt-3 text-sm text-emerald-600">
Email válido según esquema zod.
</p>
)}
</section>
</main>
)
}

View File

@@ -0,0 +1,215 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
import { useEffect, 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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'
export function RootLayout() {
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())
}, [])
useEffect(() => {
if (currentComplexSlug) return
const fallbackSlug = myComplexesQuery.data?.[0]?.complexSlug
if (fallbackSlug) {
setCurrentComplexSlug(fallbackSlug)
persistCurrentComplexSlug(fallbackSlug)
}
}, [currentComplexSlug, myComplexesQuery.data])
const handleSignOut = async () => {
await signOut()
await navigate({ to: '/login' })
}
return (
<div className="flex min-h-screen flex-col">
<header className="mx-auto flex w-full max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-6">
<h1 className="text-sm font-semibold">TanStack Router</h1>
<nav className="hidden items-center gap-3 text-sm md:flex">
<Link
to="/"
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
Home
</Link>
<Link
to="/about"
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
About
</Link>
{isAuthenticated && currentComplexSlug && (
<Link
to="/complex/$slug/edit"
params={{ slug: currentComplexSlug }}
className="text-muted-foreground"
activeProps={{ className: 'text-foreground font-medium' }}
>
Configuración
</Link>
)}
</nav>
</div>
<div className="flex items-center gap-2">
<ThemeSwitcher />
{!isAuthenticated && (
<Button asChild size="sm" variant="outline" className="hidden md:inline-flex">
<Link to="/login">Login</Link>
</Button>
)}
{isAuthenticated && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="hidden items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted md:inline-flex"
>
<Avatar size="sm">
<AvatarImage src={avatarUrl ?? undefined} alt={displayName} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<span className="max-w-32 truncate text-sm text-foreground">
{displayName}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' })
}}
>
<UserRound className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut()
}}
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
className="md:hidden"
aria-label="Abrir menú"
>
<Menu className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 md:hidden">
<DropdownMenuLabel>Navegación</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/' })
}}
>
Home
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/about' })
}}
>
About
</DropdownMenuItem>
{isAuthenticated && currentComplexSlug && (
<DropdownMenuItem
onSelect={() => {
void navigate({
to: '/complex/$slug/edit',
params: { slug: currentComplexSlug },
})
}}
>
Configuración
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{isAuthenticated ? (
<>
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/profile' })
}}
>
<UserRound className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => {
void handleSignOut()
}}
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem
onSelect={() => {
void navigate({ to: '/login' })
}}
>
Login
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="flex-1">
<Outlet />
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { Laptop, Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTheme } from '@/lib/theme'
export function ThemeSwitcher() {
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" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel>Apariencia</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={theme}
onValueChange={(value) => setTheme(value as 'light' | 'dark' | 'system')}
>
<DropdownMenuRadioItem value="light">
<Sun className="size-4" />
Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="size-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Laptop className="size-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,100 @@
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'
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 LoginPageProps = {
redirectTo?: string
}
export function LoginPage({ redirectTo = '/' }: LoginPageProps) {
const navigate = useNavigate()
const { signInWithPassword } = useAuth()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
mode: 'onChange',
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = async (values: LoginForm) => {
setSubmitError(null)
try {
await signInWithPassword(values)
await navigate({ to: redirectTo })
} catch {
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">
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<h1 className="mb-2 text-2xl font-semibold">Iniciar sesión</h1>
<p className="mb-4 text-sm text-muted-foreground">
Ingresá con tu cuenta para acceder a Home.
</p>
<form className="space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.email)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(errors.email)}
{...register('email')}
/>
<FieldError errors={[errors.email]} />
</Field>
<Field data-invalid={Boolean(errors.password)}>
<FieldLabel htmlFor="password">Contraseña</FieldLabel>
<Input
id="password"
type="password"
placeholder="••••••••"
aria-invalid={Boolean(errors.password)}
{...register('password')}
/>
<FieldError errors={[errors.password]} />
</Field>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{isSubmitting ? 'Ingresando...' : 'Ingresar'}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
¿Solo querés explorar?{' '}
<Link to="/about" className="text-primary hover:underline">
Ir a About
</Link>
</p>
</section>
</main>
)
}

View File

@@ -0,0 +1,40 @@
import { Link } from '@tanstack/react-router'
import { Compass, Home, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
return (
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-6 py-10">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_20%_20%,rgba(56,189,248,0.14),transparent_45%),radial-gradient(circle_at_80%_10%,rgba(244,114,182,0.12),transparent_40%),radial-gradient(circle_at_50%_90%,rgba(59,130,246,0.10),transparent_40%)]" />
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs text-muted-foreground">
<Sparkles className="size-3.5" />
Error de navegación
</div>
<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.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button asChild>
<Link to="/" preload="intent">
<Home className="size-4" />
Ir al inicio
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/onboard" preload="intent">
<Compass className="size-4" />
Ir a onboard
</Link>
</Button>
</div>
</section>
</main>
)
}

View File

@@ -0,0 +1,7 @@
export function OnboardingCheckEmailStep() {
return (
<p className="text-sm text-muted-foreground">
Revisa tu correo y abre el link de verificacion para continuar.
</p>
)
}

View File

@@ -0,0 +1,119 @@
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'
type OnboardingCompleteStepProps = {
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,
plans,
verifiedEmail,
errorMessage,
infoMessage,
planPlaceholder,
onSubmit,
}: OnboardingCompleteStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
{verifiedEmail && (
<Field>
<FieldLabel htmlFor="verified-email">Email verificado</FieldLabel>
<Input id="verified-email" type="email" value={verifiedEmail} disabled readOnly />
</Field>
)}
<Field data-invalid={Boolean(form.formState.errors.password)}>
<FieldLabel htmlFor="onboard-password">Contrasena</FieldLabel>
<Input
id="onboard-password"
type="password"
placeholder="********"
aria-invalid={Boolean(form.formState.errors.password)}
{...form.register('password')}
/>
<FieldError errors={[form.formState.errors.password]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.complexName)}>
<FieldLabel htmlFor="complex-name">Nombre del complejo</FieldLabel>
<Input
id="complex-name"
type="text"
placeholder="Complejo Las Palmeras"
aria-invalid={Boolean(form.formState.errors.complexName)}
{...form.register('complexName')}
/>
<FieldError errors={[form.formState.errors.complexName]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.physicalAddress)}>
<FieldLabel htmlFor="physical-address">Direccion fisica</FieldLabel>
<Input
id="physical-address"
type="text"
placeholder="Ej: Av. San Martin 1234, Salta"
aria-invalid={Boolean(form.formState.errors.physicalAddress)}
{...form.register('physicalAddress')}
/>
<FieldError errors={[form.formState.errors.physicalAddress]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.planCode)}>
<FieldLabel htmlFor="plan-code">Plan</FieldLabel>
<Controller
control={form.control}
name="planCode"
render={({ field }) => (
<select
id="plan-code"
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
aria-invalid={Boolean(form.formState.errors.planCode)}
value={field.value ?? ''}
onChange={(event) => {
field.onChange(event.target.value)
}}
disabled={plans.length === 0}
>
<option value="">
{plans.length > 0 ? planPlaceholder : 'No hay planes disponibles'}
</option>
{plans.map((plan) => (
<option key={plan.code} value={plan.code}>
{plan.name} - ${plan.price.toFixed(2)}
</option>
))}
</select>
)}
/>
{plans.length === 0 && (
<p className="text-xs text-muted-foreground">
Carga planes en la base para continuar con el onboarding.
</p>
)}
<FieldError errors={[form.formState.errors.planCode]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Finalizando...' : 'Completar onboarding'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren } from 'react'
type OnboardingLayoutProps = PropsWithChildren<{
title: string
description: string
}>
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">
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="mb-4 text-sm text-muted-foreground">{description}</p>
{children}
</section>
</main>
)
}

View File

@@ -0,0 +1,58 @@
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'
type OnboardingStartStepProps = {
form: UseFormReturn<StartValues>
errorMessage: string | null
infoMessage: string | null
onSubmit: (values: StartValues) => Promise<void>
}
export function OnboardingStartStep({
form,
errorMessage,
infoMessage,
onSubmit,
}: OnboardingStartStepProps) {
return (
<form className="space-y-3" onSubmit={form.handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(form.formState.errors.fullName)}>
<FieldLabel htmlFor="onboard-fullname">Nombre</FieldLabel>
<Input
id="onboard-fullname"
type="text"
placeholder="Nombre Apellido"
aria-invalid={Boolean(form.formState.errors.fullName)}
{...form.register('fullName')}
/>
<FieldError errors={[form.formState.errors.fullName]} />
</Field>
<Field data-invalid={Boolean(form.formState.errors.email)}>
<FieldLabel htmlFor="onboard-email">Email</FieldLabel>
<Input
id="onboard-email"
type="email"
placeholder="tu@email.com"
aria-invalid={Boolean(form.formState.errors.email)}
{...form.register('email')}
/>
<FieldError errors={[form.formState.errors.email]} />
</Field>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Enviando...' : 'Enviar codigo OTP'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,108 @@
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 {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@/components/ui/input-otp'
import type { VerifyOtpValues } from '@/features/onboard/onboarding.types'
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>
}
export function OnboardingVerifyOtpStep({
form,
email,
errorMessage,
infoMessage,
remainingAttempts,
resendCooldownSeconds,
isResending,
onSubmit,
onResend,
}: OnboardingVerifyOtpStepProps) {
const resendDisabled = resendCooldownSeconds > 0 || isResending
return (
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<p className="text-sm text-muted-foreground">
Ingresa el codigo de 6 digitos que enviamos a{' '}
<span className="font-medium text-foreground">{email ?? 'tu email'}</span>.
</p>
<Field data-invalid={Boolean(form.formState.errors.otp)}>
<div className="mb-2 flex items-center justify-between gap-3">
<FieldLabel htmlFor="onboard-otp">Codigo de verificacion</FieldLabel>
<Button
type="button"
variant="outline"
size="sm"
disabled={resendDisabled}
onClick={() => {
void onResend()
}}
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{isResending
? 'Reenviando...'
: resendCooldownSeconds > 0
? `Reenviar en ${resendCooldownSeconds}s`
: 'Reenviar codigo'}
</Button>
</div>
<InputOTP
id="onboard-otp"
maxLength={6}
inputMode="numeric"
pattern="\d*"
value={form.watch('otp')}
onChange={(value) => {
form.setValue('otp', value, { shouldValidate: true })
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<FieldError errors={[form.formState.errors.otp]} />
</Field>
<p className="text-xs text-muted-foreground">
Intentos restantes: <span className="font-medium">{remainingAttempts}</span>
</p>
{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{infoMessage && <p className="text-sm text-muted-foreground">{infoMessage}</p>}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting || !form.formState.isValid}
>
{form.formState.isSubmitting ? 'Verificando...' : 'Verificar codigo'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,7 @@
export function OnboardingVerifyingStep() {
return (
<p className="text-sm text-muted-foreground">
Verificando tu email, espera un momento...
</p>
)
}

View File

@@ -0,0 +1,241 @@
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 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'
const OTP_MAX_ATTEMPTS = 5
type OnboardStep = 'start' | 'verify-otp' | 'complete'
export function OnboardPage() {
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 startForm = useForm<StartValues>({
resolver: zodResolver(onboardingStartSchema),
mode: 'onChange',
defaultValues: {
fullName: '',
email: '',
},
})
const completeForm = useForm<CompleteValues>({
resolver: zodResolver(
onboardingCompleteSchema.omit({ onboardingRequestId: true }),
),
mode: 'onChange',
defaultValues: {
password: '',
complexName: '',
physicalAddress: '',
planCode: '',
},
})
const verifyOtpForm = useForm<VerifyOtpValues>({
resolver: zodResolver(onboardingVerifyOtpSchema.omit({ requestId: true })),
mode: 'onChange',
defaultValues: {
otp: '',
},
})
useEffect(() => {
if (step !== 'complete') return
void (async () => {
try {
const result = await apiClient.plans.list()
setPlans(result)
} catch {
setPlans([])
}
})()
}, [step])
const planPlaceholder = useMemo(() => {
if (plans.length === 0) return 'Codigo del plan (por ejemplo: BASIC)'
return 'Selecciona un plan'
}, [plans.length])
useEffect(() => {
if (resendCooldownSeconds <= 0) return
const timeout = window.setTimeout(() => {
setResendCooldownSeconds((current) => Math.max(0, current - 1))
}, 1000)
return () => {
window.clearTimeout(timeout)
}
}, [resendCooldownSeconds])
const onSubmitStart = async (values: StartValues) => {
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)
} catch {
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(null)
try {
const result = await apiClient.onboarding.verifyOtp({
requestId,
otp: values.otp,
})
setRemainingAttempts(result.remainingAttempts)
setResendCooldownSeconds(result.cooldownSeconds)
setInfoMessage(result.message)
if (result.verified) {
setVerifiedEmail(result.email ?? onboardingEmail)
setStep('complete')
return
}
} catch {
setErrorMessage('No pudimos verificar el OTP. Intenta nuevamente.')
}
}
const onResendOtp = async () => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
}
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)
} catch {
setErrorMessage('No pudimos reenviar el OTP. Intenta nuevamente.')
} finally {
setIsResending(false)
}
}
const onSubmitComplete = async (values: CompleteValues) => {
if (!requestId) {
setErrorMessage('No encontramos una solicitud de onboarding activa.')
return
}
setErrorMessage(null)
try {
const result = await apiClient.onboarding.complete({
onboardingRequestId: requestId,
password: values.password,
complexName: values.complexName,
physicalAddress: values.physicalAddress,
planCode: values.planCode,
})
setCurrentComplexSlug(result.complexSlug)
const emailForSignIn = verifiedEmail ?? onboardingEmail
if (emailForSignIn) {
await signInWithPassword({ email: emailForSignIn, password: values.password })
}
await navigate({ to: '/complex/$slug/edit', params: { slug: result.complexSlug } })
} catch {
setErrorMessage('No pudimos completar el onboarding. Intenta nuevamente.')
}
}
return (
<OnboardingLayout
title="Onboarding"
description="Crea tu cuenta y configura tu complejo de canchas."
>
{step === 'start' && (
<OnboardingStartStep
form={startForm}
errorMessage={errorMessage}
infoMessage={infoMessage}
onSubmit={onSubmitStart}
/>
)}
{step === 'verify-otp' && (
<OnboardingVerifyOtpStep
form={verifyOtpForm}
email={onboardingEmail}
errorMessage={errorMessage}
infoMessage={infoMessage}
remainingAttempts={remainingAttempts}
resendCooldownSeconds={resendCooldownSeconds}
isResending={isResending}
onSubmit={onSubmitVerifyOtp}
onResend={onResendOtp}
/>
)}
{step === 'complete' && (
<OnboardingCompleteStep
form={completeForm}
plans={plans}
verifiedEmail={verifiedEmail}
errorMessage={errorMessage}
infoMessage={infoMessage}
planPlaceholder={planPlaceholder}
onSubmit={onSubmitComplete}
/>
)}
</OnboardingLayout>
)
}

View File

@@ -0,0 +1,16 @@
import {
onboardingCompleteSchema,
onboardingStartSchema,
onboardingVerifyOtpSchema,
} 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'
>

View File

@@ -0,0 +1,71 @@
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import type { UserProfileResponse } from '@repo/api-contract'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { apiClient } from '@/lib/api-client'
import { useAuth } from '@/lib/auth'
export function ProfilePage() {
const { user, displayName, initials, avatarUrl, isAuthenticated } = useAuth()
const profileQuery = useQuery({
queryKey: ['user-profile'],
enabled: isAuthenticated,
queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(),
})
if (!isAuthenticated) {
return (
<main className="mx-auto flex min-h-[60vh] 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">
<h1 className="text-2xl font-semibold">Perfil</h1>
<p className="mt-2 text-sm text-muted-foreground">
Necesitás iniciar sesión para ver tu perfil.
</p>
<Button asChild className="mt-4">
<Link to="/login">Ir a Login</Link>
</Button>
</section>
</main>
)
}
return (
<main className="mx-auto flex min-h-[60vh] 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">
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarImage
src={profileQuery.data?.avatarUrl ?? avatarUrl ?? undefined}
alt={displayName}
/>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-semibold">
{profileQuery.data?.fullName ?? displayName}
</h1>
<p className="text-sm text-muted-foreground">
{profileQuery.data?.email ?? user?.email}
</p>
{profileQuery.data?.role && (
<p className="text-sm text-muted-foreground">
Rol: {profileQuery.data.role}
</p>
)}
</div>
</div>
{profileQuery.isLoading && (
<p className="mt-4 text-sm text-muted-foreground">Cargando perfil...</p>
)}
{profileQuery.isError && (
<p className="mt-4 text-sm text-destructive">
No se pudo cargar el perfil desde el backend.
</p>
)}
</section>
</main>
)
}

View File

@@ -0,0 +1,111 @@
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'
type PublicBookingConfirmationPageProps = {
complexSlug: string
bookingCode: string
}
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || 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)
return new Intl.DateTimeFormat('es-AR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(date)
}
export function PublicBookingConfirmationPage({
complexSlug,
bookingCode,
}: PublicBookingConfirmationPageProps) {
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">
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
{confirmationQuery.isLoading && (
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
)}
{confirmationQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(
confirmationQuery.error,
'No pudimos cargar la confirmacion de la reserva.',
)}
</p>
)}
{confirmationQuery.data && (
<div className="space-y-6">
<div>
<p className="text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
Reserva confirmada
</p>
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl">
{confirmationQuery.data.complexName}
</h1>
</div>
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 text-center sm:p-5">
<p className="text-xs text-muted-foreground">Codigo de reserva</p>
<p className="mt-1 text-3xl font-bold tracking-[0.25em] text-primary sm:text-4xl">
{confirmationQuery.data.bookingCode}
</p>
</div>
<div className="rounded-2xl border bg-background p-4 sm:p-5">
<p className="text-sm text-muted-foreground">Fecha</p>
<p className="mt-1 text-base font-medium capitalize sm:text-lg">
{formatDateLabel(confirmationQuery.data.date)}
</p>
<p className="mt-4 text-sm text-muted-foreground">Horario</p>
<p className="mt-1 text-base font-medium sm:text-lg">
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
</p>
<p className="mt-4 text-sm text-muted-foreground">Cancha</p>
<p className="mt-1 text-base font-medium sm:text-lg">
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
</p>
</div>
<Button
type="button"
className="h-11 w-full text-sm sm:text-base"
onClick={() => {
void navigate({
to: '/$complexSlug/booking',
params: { complexSlug },
})
}}
>
Hacer otra reserva
</Button>
</div>
)}
</section>
</div>
</main>
)
}

View File

@@ -0,0 +1,564 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ApiClientError, apiClient } from '@/lib/api-client'
const DAYS_STEP = 5
const bookingFormSchema = z.object({
customerName: z
.string()
.trim()
.min(2, 'Ingresa tu nombre.')
.max(120, 'El nombre no puede superar los 120 caracteres.'),
customerPhone: z
.string()
.trim()
.min(6, 'Ingresa un telefono valido.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
})
type BookingFormValues = z.infer<typeof bookingFormSchema>
type PublicBookingPageProps = {
complexSlug: string
}
type SelectedSlot = {
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
}
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}`
}
function toMinutes(value: string): number {
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 dayMonth = new Intl.DateTimeFormat('es-AR', {
day: '2-digit',
month: '2-digit',
}).format(date)
return {
weekday: weekday.replace('.', ''),
dayMonth,
}
}
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || 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 dayOptions = useMemo(() => {
return Array.from({ length: DAYS_STEP }).map((_, index) => {
const current = addDays(today, windowStartOffset + index)
return {
value: toIsoDateLocal(current),
date: current,
...formatDayLabel(current),
}
})
}, [today, windowStartOffset])
const [selectedDate, setSelectedDate] = useState(dayOptions[0]?.value ?? '')
useEffect(() => {
if (dayOptions.some((option) => option.value === selectedDate)) {
return
}
setSelectedDate(dayOptions[0]?.value ?? '')
}, [dayOptions, selectedDate])
const availabilityQuery = useQuery({
queryKey: ['public-booking-availability', complexSlug, selectedDate, selectedSportId],
enabled: Boolean(selectedDate),
queryFn: () =>
apiClient.publicBookings.getAvailability(complexSlug, {
date: selectedDate,
...(selectedSportId ? { sportId: selectedSportId } : {}),
}),
})
useEffect(() => {
const availability = availabilityQuery.data
if (!availability) return
if (!availability.sportSelectionRequired) {
if (!selectedSportId && availability.sports[0]) {
setSelectedSportId(availability.sports[0].id)
}
return
}
if (
selectedSportId &&
!availability.sports.some((sport) => sport.id === selectedSportId)
) {
setSelectedSportId(undefined)
}
}, [availabilityQuery.data, selectedSportId])
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isValid },
} = useForm<BookingFormValues>({
resolver: zodResolver(bookingFormSchema),
mode: 'onChange',
defaultValues: {
customerName: '',
customerPhone: '',
},
})
const createBookingMutation = useMutation({
mutationFn: (payload: BookingFormValues) => {
if (!selectedSlot) {
throw new Error('Debes seleccionar un horario.')
}
return apiClient.publicBookings.create(complexSlug, {
date: selectedDate,
sportId: selectedSportId,
courtId: selectedSlot.courtId,
startTime: selectedSlot.startTime,
customerName: payload.customerName,
customerPhone: payload.customerPhone,
})
},
})
const onSubmit = async (values: BookingFormValues) => {
setNavigationError(null)
const booking = await createBookingMutation.mutateAsync(values)
if (!booking.bookingCode) {
setNavigationError('No se pudo obtener el codigo de confirmacion de la reserva.')
return
}
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 ?? []
if (selectedDate !== todayIsoDate) {
return courts
}
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,
),
}))
.filter((court) => court.availableSlots.length > 0)
}, [availabilityQuery.data?.courts, selectedDate, todayIsoDate])
useEffect(() => {
if (!selectedSlot) return
const stillAvailable = visibleCourts.some(
(court) =>
court.courtId === selectedSlot.courtId &&
court.availableSlots.some((slot) => slot.startTime === selectedSlot.startTime),
)
if (!stillAvailable) {
setSelectedSlot(null)
}
}, [selectedSlot, visibleCourts])
useEffect(() => {
if (hasAutoAdjustedInitialDate) return
if (selectedDate !== todayIsoDate) {
setHasAutoAdjustedInitialDate(true)
return
}
if (availabilityQuery.isLoading || availabilityQuery.isError || !availabilityQuery.data) {
return
}
if (availabilityQuery.data.sportSelectionRequired && !selectedSportId) {
return
}
if (visibleCourts.length > 0) {
setHasAutoAdjustedInitialDate(true)
return
}
let cancelled = false
const findNextAvailableDate = async () => {
for (let dayOffset = 1; dayOffset <= 30; dayOffset += 1) {
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
}
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
} catch {
break
}
}
if (!cancelled) {
setHasAutoAdjustedInitialDate(true)
}
}
void findNextAvailableDate()
return () => {
cancelled = true
}
}, [
availabilityQuery.data,
availabilityQuery.isError,
availabilityQuery.isLoading,
complexSlug,
hasAutoAdjustedInitialDate,
selectedDate,
selectedSportId,
today,
todayIsoDate,
visibleCourts.length,
])
const canGoBack = windowStartOffset > 0
const goToPreviousFiveDays = () => {
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 goToNextFiveDays = () => {
setWindowStartOffset((previous) => {
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">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4 px-4 py-4 sm:gap-6 sm:px-6 sm:py-8">
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h1 className="text-xl font-semibold sm:text-2xl">Reserva tu turno</h1>
<p className="mt-2 text-sm text-muted-foreground">
Elige un dia, revisa horarios disponibles y confirma con tu nombre y telefono.
</p>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="space-y-3">
<div>
<h2 className="text-sm font-medium">Dia</h2>
<p className="text-xs text-muted-foreground">Mostramos 5 dias por vez.</p>
</div>
{autoAdjustedDateNotice && (
<p className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
{autoAdjustedDateNotice}
</p>
)}
<div className="grid grid-cols-[2.25rem_repeat(5,minmax(0,1fr))_2.25rem] gap-2">
{canGoBack ? (
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToPreviousFiveDays}
aria-label="Mostrar 5 dias anteriores"
>
{'<'}
</Button>
) : (
<div />
)}
{dayOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
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
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background hover:bg-muted'
}`}
>
<p className="text-[11px] font-medium uppercase sm:text-xs">{option.weekday}</p>
<p className="text-xs sm:text-sm">{option.dayMonth}</p>
</button>
))}
<Button
type="button"
variant="outline"
size="icon"
className="h-full"
onClick={goToNextFiveDays}
aria-label="Mostrar proximos 5 dias"
>
{'>'}
</Button>
</div>
</div>
</section>
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-medium">Disponibilidad</h2>
<p className="text-xs text-muted-foreground">Selecciona cancha y horario.</p>
</div>
</div>
{availabilityQuery.data?.sportSelectionRequired && (
<div className="mb-4">
<Field>
<FieldLabel>Deporte</FieldLabel>
<Select
value={selectedSportId ?? ''}
onValueChange={(value) => {
setSelectedSportId(value)
setSelectedSlot(null)
setAutoAdjustedDateNotice(null)
}}
>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Selecciona un deporte" />
</SelectTrigger>
<SelectContent>
{availabilityQuery.data.sports.map((sport) => (
<SelectItem key={sport.id} value={sport.id}>
{sport.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
)}
{availabilityQuery.isLoading && (
<p className="text-sm text-muted-foreground">Buscando disponibilidad...</p>
)}
{availabilityQuery.isError && (
<p className="text-sm text-destructive">
{extractMessage(availabilityQuery.error, 'No pudimos cargar la disponibilidad.')}
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data?.sportSelectionRequired &&
!selectedSportId && (
<p className="text-sm text-muted-foreground">
Selecciona un deporte para ver los horarios disponibles.
</p>
)}
{!availabilityQuery.isLoading &&
!availabilityQuery.isError &&
availabilityQuery.data &&
(!availabilityQuery.data.sportSelectionRequired || selectedSportId) &&
visibleCourts.length === 0 && (
<p className="text-sm text-muted-foreground">
No hay horarios disponibles para la fecha elegida.
</p>
)}
<div className="space-y-3">
{visibleCourts.map((court) => (
<article key={court.courtId} className="rounded-xl border bg-background p-3 sm:p-4">
<div className="mb-3 flex flex-col gap-1">
<p className="text-sm font-medium sm:text-base">{court.courtName}</p>
<p className="text-xs text-muted-foreground">
{court.sport.name} · Turnos de {court.slotDurationMinutes} min
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{court.availableSlots.map((slot) => {
const isSelected =
selectedSlot?.courtId === court.courtId &&
selectedSlot.startTime === slot.startTime
return (
<button
key={`${court.courtId}-${slot.startTime}`}
type="button"
onClick={() => {
setSelectedSlot({
courtId: court.courtId,
courtName: court.courtName,
sportId: court.sport.id,
sportName: court.sport.name,
startTime: slot.startTime,
endTime: slot.endTime,
})
}}
className={`h-10 rounded-lg border text-sm transition-colors ${
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-card hover:bg-muted'
}`}
>
{slot.startTime}
</button>
)
})}
</div>
</article>
))}
</div>
</section>
{selectedSlot && (
<section className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<h2 className="text-sm font-medium">Confirmar turno</h2>
<p className="mt-1 text-xs text-muted-foreground">
{selectedSlot.courtName} · {selectedSlot.sportName} · {selectedSlot.startTime} -{' '}
{selectedSlot.endTime}
</p>
<form className="mt-4 space-y-3" onSubmit={handleSubmit(onSubmit)}>
<Field data-invalid={Boolean(errors.customerName)}>
<FieldLabel htmlFor="customerName">Nombre</FieldLabel>
<Input
id="customerName"
placeholder="Ej: Juan Perez"
aria-invalid={Boolean(errors.customerName)}
{...register('customerName')}
/>
<FieldError errors={[errors.customerName]} />
</Field>
<Field data-invalid={Boolean(errors.customerPhone)}>
<FieldLabel htmlFor="customerPhone">Telefono</FieldLabel>
<Input
id="customerPhone"
type="tel"
placeholder="Ej: 3875551234"
aria-invalid={Boolean(errors.customerPhone)}
{...register('customerPhone')}
/>
<FieldError errors={[errors.customerPhone]} />
</Field>
{createBookingMutation.isError && (
<p className="text-sm text-destructive">
{extractMessage(createBookingMutation.error, 'No pudimos confirmar el turno.')}
</p>
)}
{navigationError && (
<p className="text-sm text-destructive">{navigationError}</p>
)}
<Button type="submit" className="w-full" disabled={!isValid || isSubmitting}>
{createBookingMutation.isPending ? 'Confirmando...' : 'Confirmar turno'}
</Button>
</form>
</section>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,51 @@
import { Link, type ErrorComponentProps } from '@tanstack/react-router'
import { AlertTriangle, Home, RefreshCw, Route } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function ServerErrorPage({ error, reset }: ErrorComponentProps) {
return (
<main className="relative isolate mx-auto flex min-h-screen w-full max-w-5xl items-center justify-center overflow-hidden px-6 py-10">
<div className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_15%_10%,rgba(248,113,113,0.14),transparent_42%),radial-gradient(circle_at_80%_20%,rgba(251,191,36,0.12),transparent_38%),radial-gradient(circle_at_60%_90%,rgba(59,130,246,0.10),transparent_42%)]" />
<section className="w-full max-w-2xl rounded-2xl border bg-card/80 p-8 text-card-foreground shadow-xl backdrop-blur">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs text-muted-foreground">
<AlertTriangle className="size-3.5" />
Error interno
</div>
<p className="text-sm font-medium tracking-wide text-muted-foreground">500</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Algo salió mal</h1>
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
Ocurrió un error inesperado en la aplicación. Podés reintentar o volver a una ruta segura.
</p>
{error?.message && (
<pre className="mt-5 overflow-x-auto rounded-lg border bg-muted/50 p-3 text-xs text-muted-foreground">
{error.message}
</pre>
)}
<div className="mt-8 flex flex-wrap gap-3">
<Button type="button" onClick={() => reset()}>
<RefreshCw className="size-4" />
Reintentar
</Button>
<Button asChild variant="outline">
<Link to="/" preload="intent">
<Home className="size-4" />
Ir al inicio
</Link>
</Button>
<Button asChild variant="outline">
<Link to="/onboard" preload="intent">
<Route className="size-4" />
Ir a onboard
</Link>
</Button>
</div>
</section>
</main>
)
}