From 3e314a9b9a047a930795194cf31b914d1ce03219 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 2 Jun 2026 16:26:47 -0300 Subject: [PATCH 1/3] feat(public-booking): refactor court selection and available slots components for improved usability --- .../public-booking/public-booking-page.tsx | 162 +++++++++++++----- 1 file changed, 115 insertions(+), 47 deletions(-) diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx index db49798..8ff7e62 100644 --- a/apps/frontend/src/features/public-booking/public-booking-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx @@ -2,13 +2,6 @@ import PlayzerIcon from '@/assets/playzer-favicon-512-transparent.png'; import { Button } from '@/components/ui/button'; import { Field, FieldError, FieldLabel } from '@/components/ui/field'; import { Input } from '@/components/ui/input'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { Stepper, StepperIndicator, @@ -716,15 +709,10 @@ function PublicBookingMobile(props: BookingShellProps) { /> )} {props.courts.length > 0 ? ( - ({ - value: court.courtId, - label: court.courtName, - }))} + ) : ( !props.isLoading && ( @@ -772,16 +760,12 @@ function PublicBookingMobile(props: BookingShellProps) {
-
- -
-
- +
@@ -890,34 +874,118 @@ function MobileSportSelector({ ); } -function MobileSelect({ - label, - value, - placeholder, - options, - onValueChange, +function MobileCourtPicker({ + courts, + selectedCourtId, + onSelectCourt, }: { - label: string; - value: string; - placeholder: string; - options: { value: string; label: string }[]; - onValueChange: (value: string) => void; + courts: PublicAvailabilityCourt[]; + selectedCourtId?: string; + onSelectCourt: (courtId: string) => void; }) { return ( -
- {label} - +
+
+ {courts.map((court) => { + const isSelected = selectedCourtId === court.courtId; + + return ( + + ); + })} +
+
+ ); +} + +function MobileAvailableSlots({ + court, + selectedSlot, + onSelectSlot, +}: { + court: PublicAvailabilityCourt; + selectedSlot: SelectedSlot | null; + onSelectSlot: (slot: SelectedSlot) => void; +}) { + const slots = court.availableSlots; + + return ( +
+
+
+

Horarios disponibles

+

+ Turnos de {court.slotDurationMinutes} minutos +

+
+ + {slots.length} {slots.length === 1 ? 'turno' : 'turnos'} + +
+ + {slots.length > 0 ? ( +
+ {slots.map((slot) => { + const selected = isSlotSelected(slot, court.courtId, selectedSlot); + + return ( + + ); + })} +
+ ) : ( +

+ No hay horarios disponibles para esta cancha. +

+ )}
); } -- 2.49.1 From 9045a8c0176f15929c0765e9cdc97f6a3b959686 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 2 Jun 2026 16:37:11 -0300 Subject: [PATCH 2/3] feat(public-booking): add pricing logic to booking slots and update schemas --- .../services/public-booking.service.ts | 40 ++++++++++++++++++- .../public-booking/public-booking-page.tsx | 22 ++++++++++ packages/api-contract/src/public-booking.ts | 1 + 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts index 0ff7ff6..41bdd7b 100644 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts @@ -202,6 +202,10 @@ async function getComplexWithBookingData(complexSlug: string) { availabilities: { orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], }, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, }, orderBy: { createdAt: 'asc' }, }, @@ -226,6 +230,37 @@ async function getComplexWithBookingData(complexSlug: string) { return complex; } +function resolveSlotPrice( + court: ComplexWithPublicBookingData['courts'][number], + dayOfWeek: DayOfWeek, + slot: Slot +): number { + const slotStart = toMinutes(slot.startTime); + const slotEnd = toMinutes(slot.endTime); + const matchingRules = court.priceRules + .filter((rule) => { + if (rule.dayOfWeek && rule.dayOfWeek !== dayOfWeek) { + return false; + } + + if (!rule.startTime || !rule.endTime) { + return true; + } + + return slotStart >= toMinutes(rule.startTime) && slotEnd <= toMinutes(rule.endTime); + }) + .sort((first, second) => { + const firstSpecificity = + (first.dayOfWeek ? 1 : 0) + (first.startTime && first.endTime ? 1 : 0); + const secondSpecificity = + (second.dayOfWeek ? 1 : 0) + (second.startTime && second.endTime ? 1 : 0); + + return secondSpecificity - firstSpecificity; + }); + + return Number(matchingRules[0]?.price ?? court.basePrice); +} + function mapBookingResponse(input: { bookingId: string; bookingCode: string; @@ -432,7 +467,10 @@ export async function listPublicAvailability(complexSlug: string, query: PublicA }, slotDurationMinutes: court.slotDurationMinutes, availabilityDay: dayOfWeek, - availableSlots, + availableSlots: availableSlots.map((slot) => ({ + ...slot, + price: resolveSlotPrice(court, dayOfWeek, slot), + })), }; }) .filter((court) => court.availableSlots.length > 0); diff --git a/apps/frontend/src/features/public-booking/public-booking-page.tsx b/apps/frontend/src/features/public-booking/public-booking-page.tsx index 8ff7e62..e7b6238 100644 --- a/apps/frontend/src/features/public-booking/public-booking-page.tsx +++ b/apps/frontend/src/features/public-booking/public-booking-page.tsx @@ -77,6 +77,7 @@ type SelectedSlot = { sportName: string; startTime: string; endTime: string; + price: number; }; type BookingShellProps = { @@ -179,6 +180,18 @@ function extractMessage(error: unknown, fallback: string) { return fallback; } +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 getStep(selectedSlot: SelectedSlot | null, isValid: boolean) { if (!selectedSlot) return 1; if (!isValid) return 3; @@ -966,6 +979,7 @@ function MobileAvailableSlots({ sportName: court.sport.name, startTime: slot.startTime, endTime: slot.endTime, + price: slot.price, }) } aria-pressed={selected} @@ -1093,6 +1107,7 @@ function BookingTimeline({ sportName: court.sport.name, startTime: slot.startTime, endTime: slot.endTime, + price: slot.price, }); }} className={`absolute top-7 flex h-12 min-w-[50px] items-center justify-center rounded border text-xs transition ${ @@ -1237,6 +1252,13 @@ function SelectedSlotCard(props: BookingShellProps & { compact?: boolean }) { className={compact ? 'mt-4 space-y-3' : 'mt-5 grid gap-3'} onSubmit={handleSubmit(onSubmit)} > +
+ Precio del turno + + {formatBookingPrice(selectedSlot.price)} + +
+
diff --git a/packages/api-contract/src/public-booking.ts b/packages/api-contract/src/public-booking.ts index 00302d3..5cb2aa9 100644 --- a/packages/api-contract/src/public-booking.ts +++ b/packages/api-contract/src/public-booking.ts @@ -19,6 +19,7 @@ export const publicBookingSportSchema = z.object({ export const publicBookingSlotSchema = z.object({ startTime: z.string().regex(TIME_REGEX), endTime: z.string().regex(TIME_REGEX), + price: z.number().nonnegative(), }) export const publicAvailabilityCourtSchema = z.object({ -- 2.49.1 From 07496e6673a8fcdf14cf7454acbb2933fd689df1 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 2 Jun 2026 16:55:47 -0300 Subject: [PATCH 3/3] feat(public-booking): enhance booking confirmation with price and address details --- .../services/public-booking.service.ts | 37 ++++- .../components/share-whatsapp-button.tsx | 36 ++++- .../public-booking-confirmation-page.tsx | 141 +++++++++++++----- packages/api-contract/src/public-booking.ts | 2 + 4 files changed, 162 insertions(+), 54 deletions(-) diff --git a/apps/backend/src/modules/public-booking/services/public-booking.service.ts b/apps/backend/src/modules/public-booking/services/public-booking.service.ts index 41bdd7b..5c9361b 100644 --- a/apps/backend/src/modules/public-booking/services/public-booking.service.ts +++ b/apps/backend/src/modules/public-booking/services/public-booking.service.ts @@ -13,6 +13,16 @@ type Slot = { endTime: string; }; +type PriceableCourt = { + basePrice: unknown; + priceRules: Array<{ + dayOfWeek: DayOfWeek | null; + startTime: string | null; + endTime: string | null; + price: unknown; + }>; +}; + type ComplexWithPublicBookingData = Awaited>; const DAY_OF_WEEK_BY_INDEX: DayOfWeek[] = [ @@ -230,11 +240,7 @@ async function getComplexWithBookingData(complexSlug: string) { return complex; } -function resolveSlotPrice( - court: ComplexWithPublicBookingData['courts'][number], - dayOfWeek: DayOfWeek, - slot: Slot -): number { +function resolveSlotPrice(court: PriceableCourt, dayOfWeek: DayOfWeek, slot: Slot): number { const slotStart = toMinutes(slot.startTime); const slotEnd = toMinutes(slot.endTime); const matchingRules = court.priceRules @@ -251,9 +257,9 @@ function resolveSlotPrice( }) .sort((first, second) => { const firstSpecificity = - (first.dayOfWeek ? 1 : 0) + (first.startTime && first.endTime ? 1 : 0); + (first.dayOfWeek ? 2 : 0) + (first.startTime && first.endTime ? 1 : 0); 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; }); @@ -324,6 +330,11 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC court: { select: { name: true, + basePrice: true, + priceRules: { + where: { isActive: true }, + orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }], + }, sport: { select: { id: true, @@ -334,6 +345,7 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC complex: { select: { complexName: true, + physicalAddress: true, complexSlug: true, }, }, @@ -346,13 +358,22 @@ export async function getPublicBookingConfirmation(complexSlug: string, bookingC 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 { bookingCode: booking.bookingCode, complexName: booking.court.complex.complexName, + complexAddress: booking.court.complex.physicalAddress ?? undefined, complexSlug: booking.court.complex.complexSlug, - date: formatIsoDate(booking.bookingDate), + date, startTime: booking.startTime, endTime: booking.endTime, + price, courtName: booking.court.name, sport: { id: booking.court.sport.id, diff --git a/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx b/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx index d0a9261..46f7261 100644 --- a/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx +++ b/apps/frontend/src/features/public-booking/components/share-whatsapp-button.tsx @@ -18,16 +18,35 @@ function formatDateLabel(isoDate: string) { }).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) { const dateLabel = formatDateLabel(confirmation.date); - return [ - 'Ya reservamos cancha para jugar.', - `Complejo: ${confirmation.complexName}`, + const lines = ['Ya reservamos cancha para jugar.', `Complejo: ${confirmation.complexName}`]; + + if (confirmation.complexAddress) { + lines.push(`Direccion: ${confirmation.complexAddress}`); + } + + lines.push( `Cancha: ${confirmation.courtName} (${confirmation.sport.name})`, `Fecha: ${dateLabel}`, `Horario: ${confirmation.startTime} - ${confirmation.endTime}`, - `Codigo de reserva: ${confirmation.bookingCode}`, - ].join('\n'); + `Precio: ${formatBookingPrice(confirmation.price)}`, + `Codigo de reserva: ${confirmation.bookingCode}` + ); + + return lines.join('\n'); } export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) { @@ -36,7 +55,12 @@ export function ShareWhatsappButton({ confirmation }: ShareWhatsappButtonProps) )}`; return ( -
diff --git a/packages/api-contract/src/public-booking.ts b/packages/api-contract/src/public-booking.ts index 5cb2aa9..5d75f3f 100644 --- a/packages/api-contract/src/public-booking.ts +++ b/packages/api-contract/src/public-booking.ts @@ -80,10 +80,12 @@ export const publicBookingSchema = z.object({ export const publicBookingConfirmationSchema = z.object({ bookingCode: z.string().regex(BOOKING_CODE_REGEX), complexName: z.string(), + complexAddress: z.string().optional(), complexSlug: z.string(), date: z.string().regex(ISO_DATE_REGEX), startTime: z.string().regex(TIME_REGEX), endTime: z.string().regex(TIME_REGEX), + price: z.number().nonnegative(), courtName: z.string(), sport: publicBookingSportSchema, status: z.enum(['CONFIRMED', 'CANCELLED', 'COMPLETED']), -- 2.49.1