feat: implement public booking cancellation feature

- Added cancelPublicBooking handler to manage booking cancellations.
- Updated public booking service to include cancellation logic.
- Created cancelPublicBooking schema for input validation.
- Modified public booking confirmation page to allow users to cancel their bookings.
- Enhanced email service to send cancellation confirmation emails.
- Made customerEmail field required in booking schemas and updated related components.
- Updated API client to support cancellation requests.
- Added migration to enforce non-nullable customer_email fields in the database.
This commit is contained in:
Jose Selesan
2026-06-05 11:24:02 -03:00
parent 1210854c22
commit 260d79fc99
24 changed files with 488 additions and 105 deletions

View File

@@ -1,7 +1,17 @@
import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png';
import { Button } from '@/components/ui/button';
import { ApiClientError, apiClient } from '@/lib/api-client';
import { useQuery } from '@tanstack/react-query';
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,
@@ -12,7 +22,9 @@ import {
LoaderCircle,
Receipt,
Trophy,
XCircle,
} from 'lucide-react';
import { useState } from 'react';
import { ShareWhatsappButton } from './components/share-whatsapp-button';
type PublicBookingConfirmationPageProps = {
@@ -21,14 +33,12 @@ type PublicBookingConfirmationPageProps = {
};
function extractMessage(error: unknown, fallback: string) {
if (error instanceof ApiClientError) {
return error.message || fallback;
}
return fallback;
const msg = (error as { message?: string } | undefined)?.message;
return msg || fallback;
}
function formatDateLabel(isoDate: string) {
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);
@@ -58,23 +68,70 @@ export function PublicBookingConfirmationPage({
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 && (
{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 && (
{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>
@@ -86,16 +143,23 @@ export function PublicBookingConfirmationPage({
</div>
)}
{confirmationQuery.data && (
{displayData && (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<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>
{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">
{confirmationQuery.data.complexName}
{displayData.complexName}
</h1>
</div>
<div className="flex items-center gap-2 text-slate-600">
@@ -112,17 +176,17 @@ export function PublicBookingConfirmationPage({
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)}
{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" />
{confirmationQuery.data.startTime} - {confirmationQuery.data.endTime}
{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(confirmationQuery.data.price)}
{formatBookingPrice(displayData.price)}
</p>
</div>
</div>
@@ -136,7 +200,7 @@ export function PublicBookingConfirmationPage({
Cancha
</p>
<p className="mt-2 text-base font-semibold text-slate-950">
{confirmationQuery.data.courtName} · {confirmationQuery.data.sport.name}
{displayData.courtName} · {displayData.sport.name}
</p>
</div>
<div className="bg-white p-4">
@@ -145,32 +209,90 @@ export function PublicBookingConfirmationPage({
Código de reserva
</p>
<p className="mt-2 font-mono text-lg font-bold tracking-[0.18em] text-slate-950">
{confirmationQuery.data.bookingCode}
{displayData.bookingCode}
</p>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ShareWhatsappButton confirmation={confirmationQuery.data} />
<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 },
});
}}
>
Hacer otra reserva
<ArrowRight className="size-4" />
</Button>
</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-3 gap-2 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>
);
}