298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import PlayzerLogo from '@/assets/playzer-logo-transparent.svg';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
ResponsiveDialog,
|
|
ResponsiveDialogClose,
|
|
ResponsiveDialogContent,
|
|
ResponsiveDialogDescription,
|
|
ResponsiveDialogFooter,
|
|
ResponsiveDialogHeader,
|
|
ResponsiveDialogTitle,
|
|
} from '@/components/ui/responsive-dialog';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { useNavigate } from '@tanstack/react-router';
|
|
import {
|
|
AlertCircle,
|
|
ArrowRight,
|
|
CalendarDays,
|
|
CheckCircle2,
|
|
Clock3,
|
|
LoaderCircle,
|
|
Receipt,
|
|
Trophy,
|
|
XCircle,
|
|
} from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { ShareWhatsappButton } from './components/share-whatsapp-button';
|
|
|
|
type PublicBookingConfirmationPageProps = {
|
|
complexSlug: string;
|
|
bookingCode: string;
|
|
};
|
|
|
|
function extractMessage(error: unknown, fallback: string) {
|
|
const msg = (error as { message?: string } | undefined)?.message;
|
|
return msg || fallback;
|
|
}
|
|
|
|
function formatDateLabel(isoDate: string | undefined | null) {
|
|
if (!isoDate) return '';
|
|
const [year, month, day] = isoDate.split('-').map(Number);
|
|
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
|
|
|
const label = new Intl.DateTimeFormat('es-AR', {
|
|
weekday: 'long',
|
|
day: '2-digit',
|
|
month: 'long',
|
|
}).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({
|
|
complexSlug,
|
|
bookingCode,
|
|
}: PublicBookingConfirmationPageProps) {
|
|
const navigate = useNavigate();
|
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
|
const [customerPhone, setCustomerPhone] = useState('');
|
|
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
const [cancelledBooking, setCancelledBooking] = useState<{
|
|
date: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
courtName: string;
|
|
sport: { name: string };
|
|
bookingCode: string;
|
|
price: number;
|
|
complexName: string;
|
|
} | null>(null);
|
|
|
|
const confirmationQuery = useQuery({
|
|
queryKey: ['public-booking-confirmation', complexSlug, bookingCode],
|
|
queryFn: () => apiClient.publicBookings.getConfirmation(complexSlug, bookingCode),
|
|
});
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: (phone: string) =>
|
|
apiClient.publicBookings.cancelPublic(complexSlug, {
|
|
bookingCode,
|
|
customerPhone: phone,
|
|
}),
|
|
});
|
|
|
|
const handleCancelClick = () => {
|
|
cancelMutation.reset();
|
|
setCustomerPhone('');
|
|
setCancelError(null);
|
|
setCancelDialogOpen(true);
|
|
};
|
|
|
|
const handleConfirmCancel = async () => {
|
|
setCancelError(null);
|
|
try {
|
|
const data = await cancelMutation.mutateAsync(customerPhone);
|
|
setCancelledBooking(data);
|
|
setCancelDialogOpen(false);
|
|
setCustomerPhone('');
|
|
} catch (error: unknown) {
|
|
console.error('[Cancel]', error);
|
|
setCancelError(
|
|
(error as { message?: string } | undefined)?.message ?? 'No pudimos cancelar la reserva.'
|
|
);
|
|
}
|
|
};
|
|
|
|
const isCancelled = cancelledBooking !== null;
|
|
const displayData = cancelledBooking ?? confirmationQuery.data;
|
|
|
|
return (
|
|
<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-4 py-8 sm:px-6">
|
|
<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 && !cancelledBooking && (
|
|
<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 && !cancelledBooking && (
|
|
<div className="flex gap-3 rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
<AlertCircle className="mt-0.5 size-5 shrink-0" />
|
|
<p>
|
|
{extractMessage(
|
|
confirmationQuery.error,
|
|
'No pudimos cargar la confirmación de la reserva.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{displayData && (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
{isCancelled ? (
|
|
<div className="inline-flex items-center gap-2 rounded-full bg-red-50 px-3 py-1 text-xs font-semibold tracking-[0.14em] text-red-600 uppercase">
|
|
<XCircle className="size-4" />
|
|
Reserva cancelada
|
|
</div>
|
|
) : (
|
|
<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">
|
|
<CheckCircle2 className="size-4" />
|
|
Reserva confirmada
|
|
</div>
|
|
)}
|
|
<h1 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
|
|
{displayData.complexName}
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<img src={PlayzerLogo} alt="Playzer" className="h-9 w-auto sm:h-10" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-emerald-500/30 bg-emerald-50/80 p-5 sm:p-6">
|
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="flex items-center gap-2 text-sm font-semibold text-emerald-700">
|
|
<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(displayData.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" />
|
|
{displayData.startTime} - {displayData.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(displayData.price)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-hidden rounded-[22px] border border-slate-200 bg-slate-50/70">
|
|
<div className="grid gap-px bg-slate-200 sm:grid-cols-2">
|
|
<div className="bg-white p-4">
|
|
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
|
<Trophy className="size-4 text-emerald-600" />
|
|
Cancha
|
|
</p>
|
|
<p className="mt-2 text-base font-semibold text-slate-950">
|
|
{displayData.courtName} · {displayData.sport.name}
|
|
</p>
|
|
</div>
|
|
<div className="bg-white p-4">
|
|
<p className="flex items-center gap-2 text-sm font-medium text-slate-500">
|
|
<Receipt className="size-4 text-emerald-600" />
|
|
Código de reserva
|
|
</p>
|
|
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
|
|
{displayData.bookingCode}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isCancelled ? (
|
|
<div className="rounded-2xl bg-red-50 p-4 text-center text-sm text-red-700">
|
|
Esta reserva fue cancelada. Te enviamos un email con los detalles de la
|
|
cancelación.
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-[48px_minmax(0,1fr)_minmax(0,1fr)] gap-2 sm:grid-cols-3 sm:gap-3">
|
|
<ShareWhatsappButton confirmation={confirmationQuery.data!} />
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-12 w-full rounded-xl border-red-200 text-sm font-semibold text-red-600 hover:bg-red-50 hover:text-red-700 sm:text-base"
|
|
onClick={handleCancelClick}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="h-12 w-full rounded-xl bg-emerald-600 text-sm font-semibold text-white hover:bg-emerald-500 sm:text-base"
|
|
onClick={() => {
|
|
void navigate({
|
|
to: '/$complexSlug/booking',
|
|
params: { complexSlug },
|
|
});
|
|
}}
|
|
>
|
|
<span className="hidden sm:inline">Hacer otra reserva</span>
|
|
<span className="sm:hidden">Otra</span>
|
|
<ArrowRight className="size-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<ResponsiveDialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
|
<ResponsiveDialogContent>
|
|
<ResponsiveDialogHeader>
|
|
<ResponsiveDialogTitle>Cancelar reserva</ResponsiveDialogTitle>
|
|
<ResponsiveDialogDescription>
|
|
Ingresá el número de teléfono que usaste al hacer la reserva para confirmar la
|
|
cancelación.
|
|
</ResponsiveDialogDescription>
|
|
</ResponsiveDialogHeader>
|
|
|
|
<div className="px-6 pb-2">
|
|
<Input
|
|
type="tel"
|
|
placeholder="Ej: 1134567890"
|
|
value={customerPhone}
|
|
onChange={(e) => {
|
|
setCustomerPhone(e.target.value);
|
|
setCancelError(null);
|
|
}}
|
|
/>
|
|
{cancelError && <p className="mt-2 text-sm text-red-600">{cancelError}</p>}
|
|
</div>
|
|
|
|
<ResponsiveDialogFooter>
|
|
<ResponsiveDialogClose asChild>
|
|
<Button variant="outline">Volver</Button>
|
|
</ResponsiveDialogClose>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={!customerPhone.trim() || cancelMutation.isPending}
|
|
onClick={handleConfirmCancel}
|
|
>
|
|
{cancelMutation.isPending ? (
|
|
<LoaderCircle className="size-4 animate-spin" />
|
|
) : (
|
|
'Sí, cancelar reserva'
|
|
)}
|
|
</Button>
|
|
</ResponsiveDialogFooter>
|
|
</ResponsiveDialogContent>
|
|
</ResponsiveDialog>
|
|
</main>
|
|
);
|
|
}
|