4 Commits

Author SHA1 Message Date
9875f22d5a Merge pull request 'feat/improve-booking-ui' (#10) from feat/improve-booking-ui into development
Reviewed-on: #10
2026-06-05 14:25:06 +00:00
Jose Selesan
260d79fc99 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.
2026-06-05 11:24:02 -03:00
Jose Selesan
1210854c22 feat(booking-ui): add confirmation dialog for cancelling bookings and improve mobile UI 2026-06-04 16:39:45 -03:00
Jose Selesan
7ca784d5f5 feat(booking-ui): conditionally render action buttons based on booking status 2026-06-04 16:33:51 -03:00
25 changed files with 722 additions and 273 deletions

View File

@@ -85,7 +85,7 @@ model CourtBooking {
endTime String @map("end_time") @db.VarChar(5)
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
customerEmail String? @map("customer_email") @db.VarChar(254)
customerEmail String @map("customer_email") @db.VarChar(254)
status CourtBookingStatus @default(CONFIRMED)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -108,7 +108,7 @@ model CourtBookingLog {
newStatus CourtBookingStatus @map("new_status")
customerName String @map("customer_name") @db.VarChar(120)
customerPhone String @map("customer_phone") @db.VarChar(30)
customerEmail String? @map("customer_email") @db.VarChar(254)
customerEmail String @map("customer_email") @db.VarChar(254)
changedAt DateTime @default(now()) @map("changed_at")
@@map("court_booking_logs")

View File

@@ -0,0 +1,16 @@
/*
Warnings:
- Made the column `customer_email` on table `court_booking_logs` required. This step will fail if there are existing NULL values in that column.
- Made the column `customer_email` on table `court_bookings` required. This step will fail if there are existing NULL values in that column.
*/
-- Backfill existing NULL values with placeholder
UPDATE "court_booking_logs" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
UPDATE "court_bookings" SET "customer_email" = 'missing@playzer.app' WHERE "customer_email" IS NULL;
-- AlterTable
ALTER TABLE "court_booking_logs" ALTER COLUMN "customer_email" SET NOT NULL;
-- AlterTable
ALTER TABLE "court_bookings" ALTER COLUMN "customer_email" SET NOT NULL;

View File

@@ -1,5 +1,6 @@
type BookingEmailData = {
bookingCode: string;
complexSlug?: string;
complexName: string;
date: string;
startTime: string;
@@ -171,15 +172,30 @@ export function bookingConfirmationHtml(data: BookingEmailData): string {
</td>
</tr>
${
data.complexSlug
? `
<tr>
<td style="padding:0 20px 24px;">
<p style="margin:0;font-size:13px;color:#6b7280;text-align:center;line-height:1.5;">
Present&aacute; el c&oacute;digo de reserva al llegar al complejo.
<br />
Si necesit&aacute;s cancelar o modificar, contactate directamente con el complejo.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0" style="background-color:#059669;border-radius:12px;">
<tr>
<td style="padding:14px 32px;font-size:15px;font-weight:600;">
<a href="${APP_BASE_URL}/${data.complexSlug}/booking/confirmed/${data.bookingCode}" style="color:#ffffff;text-decoration:none;display:inline-block;">
Cancelar reserva
</a>
</td>
</tr>`;
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>`
: ''
}`;
return wrapLayout(content);
}

File diff suppressed because one or more lines are too long

View File

@@ -198,7 +198,7 @@ export type CourtBookingGroupByOutputType = {
endTime: string
customerName: string
customerPhone: string
customerEmail: string | null
customerEmail: string
status: $Enums.CourtBookingStatus
createdAt: Date
updatedAt: Date
@@ -234,7 +234,7 @@ export type CourtBookingWhereInput = {
endTime?: Prisma.StringFilter<"CourtBooking"> | string
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -250,7 +250,7 @@ export type CourtBookingOrderByWithRelationInput = {
endTime?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -270,7 +270,7 @@ export type CourtBookingWhereUniqueInput = Prisma.AtLeast<{
endTime?: Prisma.StringFilter<"CourtBooking"> | string
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -286,7 +286,7 @@ export type CourtBookingOrderByWithAggregationInput = {
endTime?: Prisma.SortOrder
customerName?: Prisma.SortOrder
customerPhone?: Prisma.SortOrder
customerEmail?: Prisma.SortOrderInput | Prisma.SortOrder
customerEmail?: Prisma.SortOrder
status?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -307,7 +307,7 @@ export type CourtBookingScalarWhereWithAggregatesInput = {
endTime?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerName?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableWithAggregatesFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringWithAggregatesFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusWithAggregatesFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CourtBooking"> | Date | string
@@ -321,7 +321,7 @@ export type CourtBookingCreateInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -337,7 +337,7 @@ export type CourtBookingUncheckedCreateInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -351,7 +351,7 @@ export type CourtBookingUpdateInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -367,7 +367,7 @@ export type CourtBookingUncheckedUpdateInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -382,7 +382,7 @@ export type CourtBookingCreateManyInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -396,7 +396,7 @@ export type CourtBookingUpdateManyMutationInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -411,7 +411,7 @@ export type CourtBookingUncheckedUpdateManyInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -532,7 +532,7 @@ export type CourtBookingCreateWithoutCourtInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -546,7 +546,7 @@ export type CourtBookingUncheckedCreateWithoutCourtInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -590,7 +590,7 @@ export type CourtBookingScalarWhereInput = {
endTime?: Prisma.StringFilter<"CourtBooking"> | string
customerName?: Prisma.StringFilter<"CourtBooking"> | string
customerPhone?: Prisma.StringFilter<"CourtBooking"> | string
customerEmail?: Prisma.StringNullableFilter<"CourtBooking"> | string | null
customerEmail?: Prisma.StringFilter<"CourtBooking"> | string
status?: Prisma.EnumCourtBookingStatusFilter<"CourtBooking"> | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CourtBooking"> | Date | string
@@ -604,7 +604,7 @@ export type CourtBookingCreateManyCourtInput = {
endTime: string
customerName: string
customerPhone: string
customerEmail?: string | null
customerEmail: string
status?: $Enums.CourtBookingStatus
createdAt?: Date | string
updatedAt?: Date | string
@@ -618,7 +618,7 @@ export type CourtBookingUpdateWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -632,7 +632,7 @@ export type CourtBookingUncheckedUpdateWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -646,7 +646,7 @@ export type CourtBookingUncheckedUpdateManyWithoutCourtInput = {
endTime?: Prisma.StringFieldUpdateOperationsInput | string
customerName?: Prisma.StringFieldUpdateOperationsInput | string
customerPhone?: Prisma.StringFieldUpdateOperationsInput | string
customerEmail?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
customerEmail?: Prisma.StringFieldUpdateOperationsInput | string
status?: Prisma.EnumCourtBookingStatusFieldUpdateOperationsInput | $Enums.CourtBookingStatus
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -742,7 +742,7 @@ export type $CourtBookingPayload<ExtArgs extends runtime.Types.Extensions.Intern
endTime: string
customerName: string
customerPhone: string
customerEmail: string | null
customerEmail: string
status: $Enums.CourtBookingStatus
createdAt: Date
updatedAt: Date

View File

@@ -1,3 +1,4 @@
import { db } from '@/lib/prisma';
import {
AdminBookingServiceError,
createAdminBooking,
@@ -16,8 +17,14 @@ export async function createAdminBookingHandler(c: AppContext) {
try {
const booking = await createAdminBooking(user.id, complexId, payload);
const complex = await db.complex.findUnique({
where: { id: complexId },
select: { complexSlug: true },
});
void sendBookingConfirmation({
bookingCode: booking.bookingCode,
complexSlug: complex?.complexSlug ?? '',
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,

View File

@@ -199,7 +199,7 @@ function mapBookingResponse(booking: {
endTime: string;
customerName: string;
customerPhone: string;
customerEmail: string | null;
customerEmail: string;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
createdAt: Date;
updatedAt: Date;
@@ -235,7 +235,7 @@ function mapBookingResponse(booking: {
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail ?? undefined,
customerEmail: booking.customerEmail,
price: booking.price ?? 0,
status: booking.status,
createdAt: booking.createdAt.toISOString(),
@@ -424,7 +424,7 @@ export async function createAdminBooking(
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail?.trim() || null,
customerEmail: input.customerEmail.trim(),
status: 'CONFIRMED',
},
include: {

View File

@@ -0,0 +1,55 @@
import { sseManager } from '@/lib/sse';
import {
PublicBookingServiceError,
cancelPublicBooking,
} from '@/modules/public-booking/services/public-booking.service';
import { sendBookingCancelled } from '@/services/booking-email.service';
import type { AppContext } from '@/types/hono';
import type { CancelPublicBookingInput } from '@repo/api-contract';
type ComplexSlugParams = { complexSlug: string };
export async function cancelPublicBookingHandler(c: AppContext) {
const { complexSlug } = c.req.valid('param' as never) as ComplexSlugParams;
const payload = c.req.valid('json' as never) as CancelPublicBookingInput;
try {
const booking = await cancelPublicBooking(complexSlug, payload);
const channel = `complex-${booking.complexId}`;
sseManager.emit(
channel,
JSON.stringify({
type: 'booking_cancelled',
booking: {
bookingCode: booking.bookingCode,
courtId: booking.courtId,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
},
})
);
void sendBookingCancelled({
bookingCode: booking.bookingCode,
complexSlug: booking.complexSlug,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,
endTime: booking.endTime,
courtName: booking.courtName,
sportName: booking.sport.name,
customerName: booking.customerName,
customerEmail: booking.customerEmail,
});
return c.json(booking);
} catch (error) {
if (error instanceof PublicBookingServiceError) {
return c.json({ message: error.message }, error.status);
}
throw error;
}
}

View File

@@ -33,6 +33,7 @@ export async function createPublicBookingHandler(c: AppContext) {
void sendBookingConfirmation({
bookingCode: booking.bookingCode,
complexSlug: booking.complexSlug,
complexName: booking.complexName,
date: booking.date,
startTime: booking.startTime,

View File

@@ -1,9 +1,14 @@
import { cancelPublicBookingHandler } from '@/modules/public-booking/handlers/cancel-public-booking.handler';
import { createPublicBookingHandler } from '@/modules/public-booking/handlers/create-public-booking.handler';
import { getPublicBookingConfirmationHandler } from '@/modules/public-booking/handlers/get-public-booking-confirmation.handler';
import { listPublicAvailabilityHandler } from '@/modules/public-booking/handlers/list-public-availability.handler';
import type { AppEnv } from '@/types/hono';
import { zValidator } from '@hono/zod-validator';
import { createPublicBookingSchema, publicAvailabilityQuerySchema } from '@repo/api-contract';
import {
cancelPublicBookingSchema,
createPublicBookingSchema,
publicAvailabilityQuerySchema,
} from '@repo/api-contract';
import { Hono } from 'hono';
import { z } from 'zod';
@@ -33,3 +38,10 @@ publicBookingRoutes.get(
zValidator('param', confirmationParamsSchema),
getPublicBookingConfirmationHandler
);
publicBookingRoutes.post(
'/complex/:complexSlug/cancel',
zValidator('param', complexSlugParamsSchema),
zValidator('json', cancelPublicBookingSchema),
cancelPublicBookingHandler
);

View File

@@ -2,6 +2,7 @@ import { randomInt } from 'node:crypto';
import { db } from '@/lib/prisma';
import { evaluatePlanUsage, parsePlanRules } from '@/modules/plan/services/plan-rules.service';
import type {
CancelPublicBookingInput,
CreatePublicBookingInput,
DayOfWeek,
PublicAvailabilityQuery,
@@ -42,7 +43,6 @@ export class PublicBookingServiceError extends Error {
constructor(message: string, status: 400 | 403 | 404 | 409) {
super(message);
this.name = 'PublicBookingServiceError';
this.status = status;
}
}
@@ -276,7 +276,7 @@ function mapBookingResponse(input: {
endTime: string;
customerName: string;
customerPhone: string;
customerEmail: string | null;
customerEmail: string;
price: number;
status: 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NOSHOW';
court: {
@@ -306,7 +306,7 @@ function mapBookingResponse(input: {
endTime: input.endTime,
customerName: input.customerName,
customerPhone: input.customerPhone,
customerEmail: input.customerEmail ?? undefined,
customerEmail: input.customerEmail,
status: input.status,
price: input.price,
createdAt: input.createdAt.toISOString(),
@@ -386,7 +386,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC
slug: booking.court.sport.slug,
},
status: booking.status,
customerEmail: booking.customerEmail ?? undefined,
customerEmail: booking.customerEmail,
createdAt: booking.createdAt.toISOString(),
};
}
@@ -631,7 +631,7 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
endTime: selectedSlot.endTime,
customerName: input.customerName.trim(),
customerPhone: input.customerPhone.trim(),
customerEmail: input.customerEmail?.trim() || null,
customerEmail: input.customerEmail.trim(),
status: 'CONFIRMED',
},
select: {
@@ -712,3 +712,108 @@ export async function createPublicBooking(complexSlug: string, input: CreatePubl
409
);
}
export async function cancelPublicBooking(complexSlug: string, input: CancelPublicBookingInput) {
const normalizedBookingCode = input.bookingCode.toUpperCase();
const booking = await db.courtBooking.findFirst({
where: {
bookingCode: normalizedBookingCode,
court: {
complex: {
complexSlug,
},
},
},
include: {
court: {
select: {
id: true,
name: true,
basePrice: true,
priceRules: {
where: { isActive: true },
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
},
sport: {
select: {
id: true,
name: true,
slug: true,
},
},
complex: {
select: {
id: true,
complexName: true,
complexSlug: true,
},
},
},
},
},
});
if (!booking) {
throw new PublicBookingServiceError('Reserva no encontrada.', 404);
}
if (booking.status !== 'CONFIRMED') {
throw new PublicBookingServiceError(
'Solo se pueden cancelar reservas en estado confirmada.',
409
);
}
if (booking.customerPhone !== input.customerPhone.trim()) {
throw new PublicBookingServiceError('Los datos ingresados no coinciden con la reserva.', 403);
}
const date = formatIsoDate(booking.bookingDate);
const { dayOfWeek } = parseIsoDate(date);
const price = resolveSlotPrice(booking.court, dayOfWeek, {
startTime: booking.startTime,
endTime: booking.endTime,
});
await db.courtBookingLog.create({
data: {
id: uuidv7(),
bookingCode: booking.bookingCode,
courtId: booking.courtId,
bookingDate: booking.bookingDate,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
previousStatus: booking.status,
newStatus: 'CANCELLED',
},
});
await db.courtBooking.delete({
where: { id: booking.id },
});
return mapBookingResponse({
bookingId: booking.id,
bookingCode: booking.bookingCode,
bookingDate: booking.bookingDate,
createdAt: booking.createdAt,
startTime: booking.startTime,
endTime: booking.endTime,
customerName: booking.customerName,
customerPhone: booking.customerPhone,
customerEmail: booking.customerEmail,
price,
status: 'CANCELLED',
court: {
id: booking.court.id,
name: booking.court.name,
complexId: booking.court.complex.id,
complexName: booking.court.complex.complexName,
complexSlug: booking.court.complex.complexSlug,
sport: booking.court.sport,
},
});
}

View File

@@ -7,6 +7,7 @@ import { sendMail } from '@/lib/mailer';
type BookingEmailInput = {
bookingCode: string;
complexSlug?: string;
complexName: string;
date: string;
startTime: string;
@@ -14,7 +15,7 @@ type BookingEmailInput = {
courtName: string;
sportName: string;
customerName: string;
customerEmail?: string;
customerEmail: string;
price?: number;
};
@@ -23,6 +24,7 @@ export async function sendBookingConfirmation(data: BookingEmailInput) {
const html = bookingConfirmationHtml({
bookingCode: data.bookingCode,
complexSlug: data.complexSlug,
complexName: data.complexName,
date: data.date,
startTime: data.startTime,

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import {
Wrench,
XCircle,
} from 'lucide-react';
import { useState } from 'react';
import { useBooking } from '../booking-provider';
function formatDate(date: string | undefined) {
@@ -70,6 +71,7 @@ export function BookingToolsDialog() {
useBooking();
const isMobile = useIsMobile();
const booking = selectedSegment?.booking;
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
const status = statusConfig[booking?.status ?? 'CONFIRMED'] ?? {
label: booking?.status,
@@ -93,8 +95,62 @@ export function BookingToolsDialog() {
closeBookingTools();
};
const cancelConfirmDialog = (
<ResponsiveDialog
open={confirmCancelOpen}
onOpenChange={(open) => !open && setConfirmCancelOpen(false)}
>
<ResponsiveDialogContent>
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>¿Cancelar reserva?</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
Esta acción no se puede deshacer. Se eliminará la reserva y la cancha volverá a estar
disponible.
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="space-y-2 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cliente</span>
<span className="text-sm font-medium">{booking?.customerName ?? '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Cancha</span>
<span className="text-sm font-medium">{booking?.courtName ?? '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Fecha</span>
<span className="text-sm font-medium">{formatDate(booking?.date) || '-'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Horario</span>
<span className="text-sm font-medium">
{booking?.startTime ?? '--:--'} a {booking?.endTime ?? '--:--'}
</span>
</div>
</div>
<ResponsiveDialogFooter>
<ResponsiveDialogClose asChild>
<Button variant="outline">Volver</Button>
</ResponsiveDialogClose>
<Button
variant="destructive"
onClick={() => {
setConfirmCancelOpen(false);
updateStatus('CANCELLED');
}}
>
, cancelar reserva
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
);
if (isMobile) {
return (
<>
<ResponsiveDialog
open={bookingToolsOpen}
onOpenChange={(open) => !open && closeBookingTools()}
@@ -106,17 +162,23 @@ export function BookingToolsDialog() {
<MobileBookingToolsSheet
status={status}
onComplete={() => updateStatus('COMPLETED')}
onCancel={() => updateStatus('CANCELLED')}
onCancel={() => setConfirmCancelOpen(true)}
onReminder={sendWhatsappReminder}
onNoShow={() => updateStatus('NOSHOW')}
/>
</ResponsiveDialogContent>
</ResponsiveDialog>
{cancelConfirmDialog}
</>
);
}
return (
<ResponsiveDialog open={bookingToolsOpen} onOpenChange={(open) => !open && closeBookingTools()}>
<>
<ResponsiveDialog
open={bookingToolsOpen}
onOpenChange={(open) => !open && closeBookingTools()}
>
<ResponsiveDialogContent
className="data-[variant=dialog]:max-w-5xl data-[variant=drawer]:max-h-[92dvh] data-[variant=drawer]:overflow-y-auto data-[variant=drawer]:px-4 data-[variant=drawer]:pb-5"
onOpenAutoFocus={(event) => event.preventDefault()}
@@ -179,6 +241,7 @@ export function BookingToolsDialog() {
<Separator className="dark:bg-slate-800 bg-slate-300" />
{booking?.status !== 'COMPLETED' && (
<section className="space-y-3">
<div>
<h3 className="font-medium text-slate-100">Acciones disponibles</h3>
@@ -202,9 +265,7 @@ export function BookingToolsDialog() {
title="Cancelar reserva"
description="El cliente avisó que no va a venir. La cancha vuelve a estar disponible."
className="border-red-500/40 bg-red-500/10 text-lg text-red-600 hover:bg-red-500/15 hover:text-red-800"
onClick={() => {
updateStatus('CANCELLED');
}}
onClick={() => setConfirmCancelOpen(true)}
/>
<ActionButton
@@ -226,6 +287,7 @@ export function BookingToolsDialog() {
/>
</div>
</section>
)}
<ResponsiveDialogFooter className="data-[variant=drawer]:px-0">
<ResponsiveDialogClose asChild>
@@ -236,6 +298,8 @@ export function BookingToolsDialog() {
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
{cancelConfirmDialog}
</>
);
}
@@ -321,6 +385,7 @@ function MobileBookingToolsSheet({
</section>
</div>
{booking?.status !== 'COMPLETED' && (
<section className="shrink-0 border-t border-border/70 bg-popover/95 px-4 py-3">
<h3 className="text-sm font-medium">Acciones</h3>
<div className="mt-3 grid grid-cols-2 gap-2">
@@ -357,6 +422,7 @@ function MobileBookingToolsSheet({
</ResponsiveDialogClose>
</ResponsiveDialogFooter>
</section>
)}
</div>
);
}

View File

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

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;
const msg = (error as { message?: string } | undefined)?.message;
return msg || fallback;
}
return 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>
{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,14 +209,28 @@ 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} />
{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"
@@ -163,14 +241,58 @@ export function PublicBookingConfirmationPage({
});
}}
>
Hacer otra reserva
<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>
);
}

View File

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

View File

@@ -52,6 +52,7 @@ export const apiClient = {
getAvailability: api.getAvailability,
create: api.createPublic,
getConfirmation: api.getConfirmation,
cancelPublic: api.cancelPublic,
},
adminBookings: {
listByComplex: api.listByComplex,

View File

@@ -10,14 +10,36 @@ 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'
) {
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;
}

View File

@@ -8,6 +8,7 @@ export * as sports from './resources/sports';
export * as plans from './resources/plans';
export {
cancelPublic,
getAvailability,
createPublic,
getConfirmation,

View File

@@ -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`,

View File

@@ -25,7 +25,7 @@ export const adminBookingSchema = z.object({
endTime: z.string().regex(TIME_REGEX),
customerName: z.string(),
customerPhone: z.string(),
customerEmail: z.string().optional(),
customerEmail: z.string(),
price: z.number().nonnegative(),
status: bookingStatusSchema,
createdAt: z.string().datetime(),
@@ -56,9 +56,7 @@ export const createAdminBookingSchema = z.object({
.max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z
.string()
.email('El email ingresado no es valido.')
.optional()
.or(z.literal('')),
.email('El email ingresado no es valido.'),
})
export const updateAdminBookingStatusSchema = z.object({

View File

@@ -98,9 +98,11 @@ export {
publicBookingSchema,
publicBookingSlotSchema,
publicBookingSportSchema,
cancelPublicBookingSchema,
} from './public-booking'
export type {
CreatePublicBookingInput,
CancelPublicBookingInput,
PublicAvailabilityCourt,
PublicAvailabilityQuery,
PublicAvailabilityResponse,

View File

@@ -59,9 +59,16 @@ export const createPublicBookingSchema = z.object({
.max(30, 'El telefono no puede superar los 30 caracteres.'),
customerEmail: z
.string()
.email('El email ingresado no es valido.')
.optional()
.or(z.literal('')),
.email('El email ingresado no es valido.'),
})
export const cancelPublicBookingSchema = z.object({
bookingCode: z.string().regex(BOOKING_CODE_REGEX, 'El codigo de reserva no es valido.'),
customerPhone: z
.string()
.trim()
.min(6, 'El telefono debe tener al menos 6 caracteres.')
.max(30, 'El telefono no puede superar los 30 caracteres.'),
})
export const publicBookingSchema = z.object({
@@ -78,7 +85,7 @@ export const publicBookingSchema = z.object({
endTime: z.string().regex(TIME_REGEX),
customerName: z.string(),
customerPhone: z.string(),
customerEmail: z.string().optional(),
customerEmail: z.string(),
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
price: z.number().nonnegative(),
createdAt: z.string().datetime(),
@@ -96,7 +103,7 @@ export const publicBookingConfirmationSchema = z.object({
courtName: z.string(),
sport: publicBookingSportSchema,
status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']),
customerEmail: z.string().optional(),
customerEmail: z.string(),
createdAt: z.string().datetime(),
})
@@ -106,5 +113,6 @@ export type PublicBookingSlot = z.infer<typeof publicBookingSlotSchema>
export type PublicAvailabilityCourt = z.infer<typeof publicAvailabilityCourtSchema>
export type PublicAvailabilityResponse = z.infer<typeof publicAvailabilityResponseSchema>
export type CreatePublicBookingInput = z.infer<typeof createPublicBookingSchema>
export type CancelPublicBookingInput = z.infer<typeof cancelPublicBookingSchema>
export type PublicBooking = z.infer<typeof publicBookingSchema>
export type PublicBookingConfirmation = z.infer<typeof publicBookingConfirmationSchema>