feat/improve-public-booking #6
@@ -13,6 +13,16 @@ type Slot = {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PriceableCourt = {
|
||||||
|
basePrice: unknown;
|
||||||
|
priceRules: Array<{
|
||||||
|
dayOfWeek: DayOfWeek | null;
|
||||||
|
startTime: string | null;
|
||||||
|
endTime: string | null;
|
||||||
|
price: unknown;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
type ComplexWithPublicBookingData = Awaited<ReturnType<typeof getComplexWithBookingData>>;
|
||||||
|
|
||||||
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [
|
||||||
@@ -230,11 +240,7 @@ async function getComplexWithBookingData(complexSlug: string) {
|
|||||||
return complex;
|
return complex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSlotPrice(
|
function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number {
|
||||||
court: ComplexWithPublicBookingData['courts'][number],
|
|
||||||
dayOfWeek: DayOfWeek,
|
|
||||||
slot: Slot
|
|
||||||
): number {
|
|
||||||
const slotStart = toMinutes(slot.startTime);
|
const slotStart = toMinutes(slot.startTime);
|
||||||
const slotEnd = toMinutes(slot.endTime);
|
const slotEnd = toMinutes(slot.endTime);
|
||||||
const matchingRules = court.priceRules
|
const matchingRules = court.priceRules
|
||||||
@@ -251,9 +257,9 @@ function resolveSlotPrice(
|
|||||||
})
|
})
|
||||||
.sort((first, second) => {
|
.sort((first, second) => {
|
||||||
const firstSpecificity =
|
const firstSpecificity =
|
||||||
(first.dayOfWeek ? 1 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
(first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0);
|
||||||
const secondSpecificity =
|
const secondSpecificity =
|
||||||
(second.dayOfWeek ? 1 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
(second.dayOfWeek ? 2 : 0) + (second.startTime && second.endTime ? 1 : 0);
|
||||||
|
|
||||||
return secondSpecificity - firstSpecificity;
|
return secondSpecificity - firstSpecificity;
|
||||||
});
|
});
|
||||||
@@ -324,6 +330,11 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
court: {
|
court: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
|
basePrice: true,
|
||||||
|
priceRules: {
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||||
|
},
|
||||||
sport: {
|
sport: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -334,6 +345,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
complex: {
|
complex: {
|
||||||
select: {
|
select: {
|
||||||
complexName: true,
|
complexName: true,
|
||||||
|
physicalAddress: true,
|
||||||
complexSlug: true,
|
complexSlug: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -346,13 +358,22 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
|
|||||||
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const date = formatIsoDate(booking.bookingDate);
|
||||||
|
const { dayOfWeek } = parseIsoDate(date);
|
||||||
|
const price = resolveSlotPrice(booking.court, dayOfWeek, {
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingCode: booking.bookingCode,
|
bookingCode: booking.bookingCode,
|
||||||
complexName: booking.court.complex.complexName,
|
complexName: booking.court.complex.complexName,
|
||||||
|
complexAddress: booking.court.complex.physicalAddress ?? undefined,
|
||||||
complexSlug: booking.court.complex.complexSlug,
|
complexSlug: booking.court.complex.complexSlug,
|
||||||
date: formatIsoDate(booking.bookingDate),
|
date,
|
||||||
startTime: booking.startTime,
|
startTime: booking.startTime,
|
||||||
endTime: booking.endTime,
|
endTime: booking.endTime,
|
||||||
|
price,
|
||||||
courtName: booking.court.name,
|
courtName: booking.court.name,
|
||||||
sport: {
|
sport: {
|
||||||
id: booking.court.sport.id,
|
id: booking.court.sport.id,
|
||||||
|
|||||||
@@ -18,16 +18,35 @@ function formatDateLabel(isoDate: string) {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBookingPrice(price: number): string {
|
||||||
|
if (price === 0) {
|
||||||
|
return 'Sin cargo';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'ARS',
|
||||||
|
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||||
|
}).format(price);
|
||||||
|
}
|
||||||
|
|
||||||
function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
function createWhatsappMessage(confirmation: PublicBookingConfirmation) {
|
||||||
const dateLabel = formatDateLabel(confirmation.date);
|
const dateLabel = formatDateLabel(confirmation.date);
|
||||||
return [
|
const lines = ['Ya reservamos cancha para jugar.', `Complejo: ${confirmation.complexName}`];
|
||||||
'Ya reservamos cancha para jugar.',
|
|
||||||
`Complejo: ${confirmation.complexName}`,
|
if (confirmation.complexAddress) {
|
||||||
|
lines.push(`Direccion: ${confirmation.complexAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
`Cancha: ${confirmation.courtName} (${confirmation.sport.name})`,
|
`Cancha: ${confirmation.courtName} (${confirmation.sport.name})`,
|
||||||
`Fecha: ${dateLabel}`,
|
`Fecha: ${dateLabel}`,
|
||||||
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
|
`Horario: ${confirmation.startTime} - ${confirmation.endTime}`,
|
||||||
`Codigo de reserva: ${confirmation.bookingCode}`,
|
`Precio: ${formatBookingPrice(confirmation.price)}`,
|
||||||
].join('\n');
|
`Codigo de reserva: ${confirmation.bookingCode}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
|
export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) {
|
||||||
@@ -36,7 +55,12 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
|||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button type="button" variant="outline" className="h-11 w-full text-sm sm:text-base" asChild>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-12 w-full rounded-xl border-slate-200 bg-white text-sm font-semibold text-slate-900 hover:bg-slate-50 sm:text-base"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer">
|
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
CalendarDays,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
LoaderCircle,
|
||||||
|
Receipt,
|
||||||
|
Trophy,
|
||||||
|
} from 'lucide-react';
|
||||||
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
||||||
|
|
||||||
type PublicBookingConfirmationPageProps = {
|
type PublicBookingConfirmationPageProps = {
|
||||||
@@ -21,12 +32,25 @@ function formatDateLabel(isoDate: string) {
|
|||||||
const [year, month, day] = isoDate.split('-').map(Number);
|
const [year, month, day] = isoDate.split('-').map(Number);
|
||||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('es-AR', {
|
const label = new Intl.DateTimeFormat('es-AR', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
|
||||||
}).format(date);
|
}).format(date);
|
||||||
|
|
||||||
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBookingPrice(price: number): string {
|
||||||
|
if (price === 0) {
|
||||||
|
return 'Sin cargo';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'ARS',
|
||||||
|
maximumFractionDigits: Number.isInteger(price) ? 0 : 2,
|
||||||
|
}).format(price);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublicBookingConfirmationPage({
|
export function PublicBookingConfirmationPage({
|
||||||
@@ -40,62 +64,98 @@ export function PublicBookingConfirmationPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-linear-to-b from-background via-background to-primary/5">
|
<main className="min-h-screen bg-[#edf7f4] text-[#111827]">
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-3 py-8 sm:px-6">
|
<div className="mx-auto flex min-h-screen w-full max-w-3xl items-center px-4 py-8 sm:px-6">
|
||||||
<section className="w-full rounded-3xl border bg-card p-5 shadow-lg sm:p-8">
|
<section className="w-full rounded-[28px] border border-emerald-950/10 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.12)] sm:p-8">
|
||||||
{confirmationQuery.isLoading && (
|
{confirmationQuery.isLoading && (
|
||||||
<p className="text-sm text-muted-foreground">Cargando confirmacion...</p>
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<LoaderCircle className="size-5 animate-spin text-emerald-600" />
|
||||||
|
Cargando confirmación...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmationQuery.isError && (
|
{confirmationQuery.isError && (
|
||||||
<p className="text-sm text-destructive">
|
<div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
{extractMessage(
|
<AlertCircle className="mt-0.5 size-5 shrink-0" />
|
||||||
confirmationQuery.error,
|
<p>
|
||||||
'No pudimos cargar la confirmacion de la reserva.'
|
{extractMessage(
|
||||||
)}
|
confirmationQuery.error,
|
||||||
</p>
|
'No pudimos cargar la confirmación de la reserva.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{confirmationQuery.data && (
|
{confirmationQuery.data && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<p className="text-xs font-medium tracking-[0.2em] text-muted-foreground uppercase">
|
<div>
|
||||||
Reserva confirmada
|
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-emerald-700 uppercase">
|
||||||
</p>
|
<CheckCircle2 className="size-4" />
|
||||||
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl">
|
Reserva confirmada
|
||||||
{confirmationQuery.data.complexName}
|
</div>
|
||||||
</h1>
|
<h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
|
||||||
|
{confirmationQuery.data.complexName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
|
<img src={PlayzerIcon} alt="Playzer" className="size-8" />
|
||||||
|
<span className="text-lg font-bold tracking-tight">Playzer</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-primary/30 bg-primary/5 p-4 text-center sm:p-5">
|
<div className="rounded-[24px] border border-emerald-500/30 bg-emerald-50/80 p-5 sm:p-6">
|
||||||
<p className="text-xs text-muted-foreground">Codigo de reserva</p>
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<p className="mt-1 text-3xl font-bold tracking-[0.25em] text-primary sm:text-4xl">
|
<div>
|
||||||
{confirmationQuery.data.bookingCode}
|
<p className="flex items-center gap-2 text-sm font-semibold text-emerald-700">
|
||||||
</p>
|
<CalendarDays className="size-4" />
|
||||||
|
Tu turno
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-2xl font-black leading-tight tracking-tight text-slate-950 sm:text-4xl">
|
||||||
|
{formatDateLabel(confirmationQuery.data.date)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 flex items-center gap-2 text-3xl font-black leading-none text-emerald-700 sm:text-5xl">
|
||||||
|
<Clock3 className="size-7 sm:size-9" />
|
||||||
|
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-emerald-600/20 bg-white/80 px-4 py-3 sm:min-w-40 sm:text-right">
|
||||||
|
<p className="text-xs font-medium text-slate-500">Precio del turno</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-slate-950">
|
||||||
|
{formatBookingPrice(confirmationQuery.data.price)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-background p-4 sm:p-5">
|
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-50/70">
|
||||||
<p className="text-sm text-muted-foreground">Fecha</p>
|
<div className="grid gap-px bg-slate-200 sm:grid-cols-2">
|
||||||
<p className="mt-1 text-base font-medium capitalize sm:text-lg">
|
<div className="bg-white p-4">
|
||||||
{formatDateLabel(confirmationQuery.data.date)}
|
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||||
</p>
|
<Trophy className="size-4 text-emerald-600" />
|
||||||
|
Cancha
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Horario</p>
|
</p>
|
||||||
<p className="mt-1 text-base font-medium sm:text-lg">
|
<p className="mt-2 text-base font-semibold text-slate-950">
|
||||||
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
|
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<p className="mt-4 text-sm text-muted-foreground">Cancha</p>
|
<div className="bg-white p-4">
|
||||||
<p className="mt-1 text-base font-medium sm:text-lg">
|
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
||||||
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
|
<Receipt className="size-4 text-emerald-600" />
|
||||||
</p>
|
Código de reserva
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
|
||||||
|
{confirmationQuery.data.bookingCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<ShareWhatsappButton confirmation={confirmationQuery.data} />
|
<ShareWhatsappButton confirmation={confirmationQuery.data} />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-11 w-full text-sm sm:text-base"
|
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: '/$complexSlug/booking',
|
to: '/$complexSlug/booking',
|
||||||
@@ -104,6 +164,7 @@ export function PublicBookingConfirmationPage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Hacer otra reserva
|
Hacer otra reserva
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ export const publicBookingSchema = z.object({
|
|||||||
export const publicBookingConfirmationSchema = z.object({
|
export const publicBookingConfirmationSchema = z.object({
|
||||||
bookingCode: z.string().regex(BOOKING_CODE_REGEX),
|
bookingCode: z.string().regex(BOOKING_CODE_REGEX),
|
||||||
complexName: z.string(),
|
complexName: z.string(),
|
||||||
|
complexAddress: z.string().optional(),
|
||||||
complexSlug: z.string(),
|
complexSlug: z.string(),
|
||||||
date: z.string().regex(ISO_DATE_REGEX),
|
date: z.string().regex(ISO_DATE_REGEX),
|
||||||
startTime: z.string().regex(TIME_REGEX),
|
startTime: z.string().regex(TIME_REGEX),
|
||||||
endTime: z.string().regex(TIME_REGEX),
|
endTime: z.string().regex(TIME_REGEX),
|
||||||
|
price: z.number().nonnegative(),
|
||||||
courtName: z.string(),
|
courtName: z.string(),
|
||||||
sport: publicBookingSportSchema,
|
sport: publicBookingSportSchema,
|
||||||
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
|
||||||
|
|||||||
Reference in New Issue
Block a user