From 9045a8c0176f15929c0765e9cdc97f6a3b959686 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Tue, 2 Jun 2026 16:37:11 -0300 Subject: [PATCH] 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({