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:
@@ -41,7 +41,7 @@ interface CreateManualBookingPayload {
|
||||
startTime: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
customerEmail?: string;
|
||||
customerEmail: string;
|
||||
}
|
||||
|
||||
interface BookingContextValue {
|
||||
@@ -316,7 +316,7 @@ export function BookingProvider({ children, complex }: BookingProviderProps) {
|
||||
startTime: payload.startTime,
|
||||
customerName: payload.customerName,
|
||||
customerPhone: payload.customerPhone,
|
||||
customerEmail: payload.customerEmail || undefined,
|
||||
customerEmail: payload.customerEmail,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-bookings', complex.id] });
|
||||
|
||||
@@ -42,7 +42,7 @@ const bookingFormSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'Ingresa un telefono válido.')
|
||||
.max(30, 'El telefono no puede superar los 30 caracteres.'),
|
||||
customerEmail: z.string().email('Ingresá un email válido.').optional().or(z.literal('')),
|
||||
customerEmail: z.string().email('Ingresá un email válido.'),
|
||||
});
|
||||
|
||||
type BookingForm = z.infer<typeof bookingFormSchema>;
|
||||
@@ -156,7 +156,7 @@ export function BookingCreateDialog() {
|
||||
startTime,
|
||||
customerName: values.customerName,
|
||||
customerPhone: values.customerPhone,
|
||||
customerEmail: values.customerEmail || undefined,
|
||||
customerEmail: values.customerEmail,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ type ShareWhatsappButtonProps = {
|
||||
confirmation: PublicBookingConfirmation;
|
||||
};
|
||||
|
||||
function formatDateLabel(isoDate: string) {
|
||||
function formatDateLabel(isoDate: string | null | undefined) {
|
||||
if (!isoDate) return '';
|
||||
const [year, month, day] = isoDate.split('-').map(Number);
|
||||
const date = new Date(year, (month ?? 1) - 1, day ?? 1);
|
||||
|
||||
@@ -61,16 +62,21 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps)
|
||||
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"
|
||||
title="Compartir por WhatsApp"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className="size-4"
|
||||
className="size-5"
|
||||
style={{ color: `#${siWhatsapp.hex}` }}
|
||||
>
|
||||
<path d={siWhatsapp.path} fill="currentColor" />
|
||||
</svg>
|
||||
Compartir por WhatsApp
|
||||
<span className="sm:hidden">Compartir por WhatsApp</span>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ const bookingFormSchema = z.object({
|
||||
.trim()
|
||||
.min(6, 'Ingresá un teléfono válido.')
|
||||
.max(30, 'El teléfono no puede superar los 30 caracteres.'),
|
||||
customerEmail: z.string().email('Ingresá un email válido.').optional().or(z.literal('')),
|
||||
customerEmail: z.string().email('Ingresá un email válido.'),
|
||||
});
|
||||
|
||||
type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||
@@ -1297,7 +1297,7 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) {
|
||||
|
||||
<Field data-invalid={Boolean(errors.customerEmail)}>
|
||||
<FieldLabel htmlFor="customerEmail" className="text-white/76">
|
||||
Email <span className="text-white/40 font-normal">(opcional)</span>
|
||||
Email
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="customerEmail"
|
||||
@@ -1445,7 +1445,7 @@ export function PublicBookingPage({ complexSlug }: PublicBookingPageProps) {
|
||||
startTime: selectedSlot.startTime,
|
||||
customerName: payload.customerName,
|
||||
customerPhone: payload.customerPhone,
|
||||
customerEmail: payload.customerEmail || undefined,
|
||||
customerEmail: payload.customerEmail,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export const apiClient = {
|
||||
getAvailability: api.getAvailability,
|
||||
create: api.createPublic,
|
||||
getConfirmation: api.getConfirmation,
|
||||
cancelPublic: api.cancelPublic,
|
||||
},
|
||||
adminBookings: {
|
||||
listByComplex: api.listByComplex,
|
||||
|
||||
@@ -10,13 +10,35 @@ export const http: AxiosInstance = axios.create({
|
||||
});
|
||||
|
||||
function extractMessage(details: unknown, fallback: string): string {
|
||||
if (
|
||||
typeof details === 'object' &&
|
||||
details &&
|
||||
'message' in details &&
|
||||
typeof details.message === 'string'
|
||||
) {
|
||||
return details.message;
|
||||
if (typeof details === 'object' && details) {
|
||||
if ('message' in details && typeof details.message === 'string') {
|
||||
return details.message;
|
||||
}
|
||||
if ('detail' in details && typeof details.detail === 'string') {
|
||||
return details.detail;
|
||||
}
|
||||
if ('error' in details && typeof details.error === 'object' && details.error) {
|
||||
const zodError = details.error as Record<string, unknown>;
|
||||
if (Array.isArray(zodError.issues) && zodError.issues.length > 0) {
|
||||
const firstIssue = zodError.issues[0] as { message?: string };
|
||||
if (typeof firstIssue.message === 'string') return firstIssue.message;
|
||||
}
|
||||
if (typeof zodError.message === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(zodError.message);
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.length > 0 &&
|
||||
typeof parsed[0]?.message === 'string'
|
||||
) {
|
||||
return parsed[0].message;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON, use raw string */
|
||||
}
|
||||
return zodError.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export * as sports from './resources/sports';
|
||||
export * as plans from './resources/plans';
|
||||
|
||||
export {
|
||||
cancelPublic,
|
||||
getAvailability,
|
||||
createPublic,
|
||||
getConfirmation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AdminBooking,
|
||||
CancelPublicBookingInput,
|
||||
CreateAdminBookingInput,
|
||||
CreatePublicBookingInput,
|
||||
PublicAvailabilityResponse,
|
||||
@@ -51,6 +52,14 @@ export async function createAdmin(complexId: string, payload: CreateAdminBooking
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function cancelPublic(complexSlug: string, payload: CancelPublicBookingInput) {
|
||||
const response = await http.post<PublicBooking>(
|
||||
`/api/public-bookings/complex/${complexSlug}/cancel`,
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateStatus(bookingId: string, payload: UpdateAdminBookingStatusInput) {
|
||||
const response = await http.patch<AdminBooking>(
|
||||
`/api/admin-bookings/${bookingId}/status`,
|
||||
|
||||
Reference in New Issue
Block a user