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

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