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:
Jose Selesan
2026-04-10 15:36:15 -03:00
parent 77004b14b0
commit 9ee98a4cb4
129 changed files with 2929 additions and 3186 deletions

View File

@@ -8,5 +8,5 @@ export function AboutPage() {
</p>
</section>
</main>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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])
);

View File

@@ -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',
},
],
}
};
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
);
}

View File

@@ -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>
)
);
}

View File

@@ -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'>;

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}

View File

@@ -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>
)
);
}