@ticketboothapp/booking 0.1.4 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +21 -1
  2. package/src/components/BookingDetails.tsx +546 -0
  3. package/src/components/BookingFlow.tsx +2952 -0
  4. package/src/components/BookingWidget.tsx +7 -5
  5. package/src/components/Calendar.tsx +906 -0
  6. package/src/components/CheckoutModal.tsx +294 -0
  7. package/src/components/CurrencySwitcher.tsx +81 -0
  8. package/src/components/ErrorBoundary.tsx +63 -0
  9. package/src/components/ItineraryBuilder.tsx +83 -0
  10. package/src/components/LanguageSwitcher.tsx +30 -0
  11. package/src/components/ManageBookingView.tsx +4 -2
  12. package/src/components/MealDrinkAddOnSelector.tsx +330 -0
  13. package/src/components/PickupLocationSelector.tsx +1541 -0
  14. package/src/components/PriceBreakdown.tsx +154 -0
  15. package/src/components/PriceSummary.tsx +211 -0
  16. package/src/components/PrivateShuttleBookingFlow.tsx +2290 -0
  17. package/src/components/ProductList.tsx +78 -0
  18. package/src/components/TermsAcceptance.tsx +110 -0
  19. package/src/components/WhatsAppPhoneInput.tsx +224 -0
  20. package/src/components/index.ts +31 -0
  21. package/src/contexts/CompanyContext.tsx +8 -20
  22. package/src/index.ts +5 -0
  23. package/src/lib/api.ts +801 -0
  24. package/src/lib/booking-ref.ts +13 -0
  25. package/src/lib/checkout-breakdown.test.ts +70 -0
  26. package/src/lib/checkout-breakdown.ts +69 -0
  27. package/src/lib/constants.ts +17 -0
  28. package/src/lib/currency.ts +88 -0
  29. package/src/lib/env.ts +10 -12
  30. package/src/lib/i18n/config.ts +21 -0
  31. package/src/lib/i18n/index.tsx +144 -0
  32. package/src/lib/i18n/messages/en.json +192 -0
  33. package/src/lib/i18n/messages/fr.json +192 -0
  34. package/src/lib/itinerary-labels.ts +70 -0
  35. package/src/lib/location-calculations.ts +43 -0
  36. package/src/lib/location-utils.ts +139 -0
  37. package/src/lib/map-utils.ts +153 -0
  38. package/src/lib/marker-icons.ts +113 -0
  39. package/src/lib/pickup-location-types.ts +25 -0
  40. package/src/lib/places-api.ts +154 -0
  41. package/src/lib/pricing.ts +466 -0
  42. package/src/lib/theme.ts +83 -0
  43. package/src/lib/utils.ts +9 -0
  44. package/src/types/google-maps.d.ts +2 -0
  45. package/tsconfig.json +8 -2
@@ -0,0 +1,906 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react';
4
+ import { startOfWeek, addDays, addWeeks, subWeeks, isSameDay, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths } from 'date-fns';
5
+ import { formatInTimeZone } from 'date-fns-tz';
6
+ import { enUS, fr } from 'date-fns/locale';
7
+ import type { Availability } from '@/lib/api';
8
+ import { useTranslations, useLocale } from '@/lib/i18n';
9
+ import {
10
+ MINI_CALENDAR_START_MONTH,
11
+ MINI_CALENDAR_END_MONTH,
12
+ MINI_CALENDAR_START_DATE,
13
+ MINI_CALENDAR_END_DATE,
14
+ VISIBLE_RANGE_BUFFER_WEEKS,
15
+ } from '@/lib/constants';
16
+ import { cn } from '@/lib/utils';
17
+
18
+ // ============ Types ============
19
+
20
+ export interface DateAvailability {
21
+ date: string; // yyyy-MM-dd format
22
+ availabilityCount: number;
23
+ isSoldOut: boolean;
24
+ minPrice?: number;
25
+ hasDiscount?: boolean;
26
+ discountPercent?: number;
27
+ totalVacancies?: number; // Total available tickets across all availabilities for this date
28
+ startTimes?: string[]; // Array of start times for this date (e.g., ["09:00", "10:00", "14:00"])
29
+ soldOutTimes?: Set<string>; // Set of sold out time strings (e.g., ["09:00", "14:00"])
30
+ totalDiscountPercent?: number; // Total discount percentage from all negative adjustments (deals + dynamic pricing)
31
+ /** Per-time booked/total capacity (for admin calendar: show "booked/total" on each slot) */
32
+ timeCapacityMap?: Record<string, { booked: number; total: number }>;
33
+ }
34
+
35
+ interface CalendarProps {
36
+ availabilitiesByDate: Record<string, Availability[]>;
37
+ selectedDate: string | null;
38
+ onDateSelect: (date: string) => void;
39
+ timezone: string;
40
+ earliestDate: Date | null;
41
+ onVisibleRangeChange?: (startDate: Date, endDate: Date) => void;
42
+ currency: string; // Currency code (e.g., "CAD", "USD") for discount calculations
43
+ /** When true (admin), show booked/total capacity on each time slot */
44
+ showCapacity?: boolean;
45
+ }
46
+
47
+ // ============ Constants ============
48
+
49
+ const DAYS_OF_WEEK_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
50
+ const WEEKS_TO_SHOW = 2;
51
+
52
+ // Date-fns locale map
53
+ const dateFnsLocales = {
54
+ en: enUS,
55
+ fr: fr,
56
+ } as const;
57
+
58
+ // ============ Helper Functions ============
59
+
60
+ /**
61
+ * Get net discount percentage for a date from percentage-type adjustments.
62
+ * - Deals: always discounts (add to total)
63
+ * - Dynamic pricing discounts (negative change): add to total
64
+ * - Dynamic pricing surcharges (positive change): subtract from total
65
+ * Result: discounts minus surcharges. Only shown if net is positive (i.e. net discount).
66
+ *
67
+ * @param availabilities All availabilities for a specific date
68
+ * @param currency Currency code to check changeByCurrency for dynamic pricing
69
+ * @returns Net discount percentage (e.g., 15 for -15% off), or undefined if net <= 0
70
+ */
71
+ function calculateTotalDiscountPercent(
72
+ availabilities: Availability[],
73
+ currency: string
74
+ ): number | undefined {
75
+ // Get the first availability with rates
76
+ const avail = availabilities.find(a => a.rates && a.rates.length > 0);
77
+ if (!avail?.rates) return undefined;
78
+
79
+ // Get ADULT rate adjustments
80
+ const adultRate = avail.rates.find(rate => rate.category === 'ADULT');
81
+ if (!adultRate) return undefined;
82
+
83
+ const adjustments = adultRate.appliedAdjustments || adultRate.applied_adjustments || [];
84
+
85
+ let totalDiscount = 0;
86
+ let totalSurcharge = 0;
87
+ const seenIds = new Set<string>();
88
+
89
+ for (const adj of adjustments) {
90
+ if (seenIds.has(adj.id)) continue;
91
+ if (adj.adjustmentType !== 'percentage' || !adj.adjustmentValue) continue;
92
+
93
+ seenIds.add(adj.id);
94
+
95
+ if (adj.type === 'deal') {
96
+ // Deals are always discounts
97
+ totalDiscount += Math.abs(adj.adjustmentValue);
98
+ } else if (adj.type === 'dynamic') {
99
+ const change = adj.changeByCurrency?.[currency];
100
+ if (change !== undefined) {
101
+ if (change < 0) {
102
+ totalDiscount += Math.abs(adj.adjustmentValue);
103
+ } else {
104
+ totalSurcharge += Math.abs(adj.adjustmentValue);
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ const net = totalDiscount - totalSurcharge;
111
+ return net > 0 ? Math.round(net) : undefined;
112
+ }
113
+
114
+ // ============ Date Cell Component ============
115
+
116
+ interface DateCellProps {
117
+ date: Date;
118
+ dateStr: string;
119
+ availability: DateAvailability | null;
120
+ isSelected: boolean;
121
+ isToday: boolean;
122
+ showCapacity?: boolean;
123
+ timezone: string;
124
+ onClick: () => void;
125
+ }
126
+
127
+ const DateCell = memo(function DateCell({
128
+ date,
129
+ availability,
130
+ isSelected,
131
+ isToday,
132
+ showCapacity,
133
+ timezone,
134
+ onClick,
135
+ }: DateCellProps) {
136
+ const { t } = useTranslations();
137
+ const { locale } = useLocale();
138
+ const dateFnsLocale = dateFnsLocales[locale] || enUS;
139
+ const dayNumber = formatInTimeZone(date, timezone, 'd');
140
+ // When showCapacity (admin), allow selecting sold-out slots for overbooking
141
+ const hasAvailability = availability !== null && (!availability.isSoldOut || showCapacity);
142
+ const isDisabled = !hasAvailability;
143
+
144
+ // Format time for display (e.g., "09:00" -> "9:00 AM" or "9:00" in 24h format)
145
+ // Memoized to prevent recreation on every render
146
+ const formatTime = useCallback((timeStr: string) => {
147
+ try {
148
+ const [hours, minutes] = timeStr.split(':').map(Number);
149
+ const date = new Date();
150
+ date.setHours(hours, minutes, 0, 0);
151
+ // Use 12-hour format for English, 24-hour format for French
152
+ const format = locale === 'fr' ? 'HH:mm' : 'h:mm a';
153
+ return formatInTimeZone(date, timezone, format, { locale: dateFnsLocale });
154
+ } catch {
155
+ return timeStr;
156
+ }
157
+ }, [locale, timezone, dateFnsLocale]);
158
+
159
+ // Two heights: tall when >2 time slots (discount + times); otherwise short. No aspect on outer so cell can stretch to row.
160
+ const needsTallerCell = (availability?.startTimes?.length ?? 0) > 2;
161
+ const buttonClassName = useMemo(() => cn(
162
+ 'h-full w-full min-h-0',
163
+ needsTallerCell ? 'min-w-0 min-h-[7.75rem]' : 'min-h-[6.25rem]',
164
+ 'px-2 py-0 border-r border-b border-stone-200 last:border-r-0',
165
+ 'transition-all text-left relative',
166
+ isDisabled
167
+ ? 'bg-stone-50 text-stone-300 cursor-not-allowed'
168
+ : isSelected
169
+ ? 'bg-emerald-600 text-white hover:bg-emerald-700 cursor-pointer'
170
+ : 'bg-white text-stone-900 hover:bg-stone-50 cursor-pointer',
171
+ isToday && hasAvailability && !isSelected && 'ring-1 ring-emerald-500 ring-inset'
172
+ ), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell]);
173
+
174
+ const dayNumberClassName = useMemo(() => cn(
175
+ 'text-[10px] font-medium absolute top-1 left-1',
176
+ isSelected && hasAvailability ? 'text-white' : 'text-stone-900'
177
+ ), [isSelected, hasAvailability]);
178
+
179
+ const ariaLabel = useMemo(() => {
180
+ const parts = [dayNumber];
181
+ if (availability?.isSoldOut) parts.push(' - Sold out');
182
+ if (availability?.hasDiscount) parts.push(` - ${availability.discountPercent}% off`);
183
+ return parts.join('');
184
+ }, [dayNumber, availability?.isSoldOut, availability?.hasDiscount, availability?.discountPercent]);
185
+
186
+ return (
187
+ <button
188
+ onClick={onClick}
189
+ disabled={isDisabled}
190
+ className={buttonClassName}
191
+ aria-label={ariaLabel}
192
+ >
193
+ <div className="flex flex-col h-full items-center justify-center">
194
+ {/* Day Number */}
195
+ <div className={dayNumberClassName}>
196
+ {dayNumber}
197
+ </div>
198
+
199
+ {/* Discount Tag Icon - Top Right Corner */}
200
+ {availability?.totalDiscountPercent && availability.totalDiscountPercent > 0 && (
201
+ <div className="absolute top-1 right-1 flex items-center gap-0.5 bg-red-500/90 text-white text-[9px] font-bold px-1 py-0.5 rounded">
202
+ <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
203
+ <path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
204
+ </svg>
205
+ <span>-{availability.totalDiscountPercent}%</span>
206
+ </div>
207
+ )}
208
+
209
+ {/* Availability Indicators - Centered */}
210
+ {availability && (
211
+ <div className="flex flex-col items-center justify-center space-y-0 w-full px-0.5">
212
+ {/* Sold Out Badge - Centered */}
213
+ {availability.isSoldOut ? (
214
+ <div className={cn(
215
+ 'text-[10px] font-semibold px-2 py-1 rounded flex items-center justify-center',
216
+ isSelected
217
+ ? 'bg-red-500/30 text-white border border-red-400/50'
218
+ : 'bg-red-100 text-red-700 border border-red-300'
219
+ )}>
220
+ {t('calendar.soldOut')}
221
+ </div>
222
+ ) : hasAvailability ? (
223
+ <>
224
+ {/* Start Times as Pills - Yellow/Orange if < 5 spots, Red if sold out, Green otherwise */}
225
+ {availability.startTimes && availability.startTimes.length > 0 ? (
226
+ <div className="flex flex-wrap gap-0.5 justify-center">
227
+ {availability.startTimes.slice(0, 3).map((time) => {
228
+ const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
229
+ const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
230
+ // Check if this is daily availability (midnight/00:00 time)
231
+ const isDailyAvailability = time === '00:00';
232
+ return (
233
+ <div
234
+ key={time}
235
+ className={cn(
236
+ 'text-[10px] px-1.5 py-1 rounded font-medium flex flex-col items-center gap-0',
237
+ isTimeSoldOut
238
+ ? isSelected
239
+ ? 'bg-red-500/30 text-white opacity-60'
240
+ : 'bg-red-400 text-white opacity-60'
241
+ : isLowAvailability
242
+ ? isSelected
243
+ ? 'bg-amber-600 text-white'
244
+ : 'bg-amber-500 text-white'
245
+ : isSelected
246
+ ? 'bg-white/30 text-white'
247
+ : 'bg-emerald-500 text-white'
248
+ )}
249
+ >
250
+ <div>{isDailyAvailability ? 'Available' : formatTime(time)}</div>
251
+ {showCapacity && availability.timeCapacityMap?.[time] && (
252
+ <div className="text-[9px] -mt-0.5 tabular-nums opacity-90">
253
+ {availability.timeCapacityMap[time].booked}/{availability.timeCapacityMap[time].total}
254
+ </div>
255
+ )}
256
+ {isTimeSoldOut ? (
257
+ <div className="text-[9px] -mt-0.5">
258
+ {t('calendar.soldOut')}
259
+ </div>
260
+ ) : isLowAvailability && !showCapacity && (
261
+ <div className="flex items-center gap-0.5 text-[9px] -mt-0.5">
262
+ <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
263
+ <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
264
+ </svg>
265
+ <span>{t('calendar.left', { count: availability.totalVacancies ?? 0 })}</span>
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+ })}
271
+ {availability.startTimes.length > 3 && (
272
+ <div className={cn(
273
+ 'text-[10px] px-1.5 py-1 rounded font-medium',
274
+ isSelected
275
+ ? 'bg-white/30 text-white'
276
+ : 'bg-emerald-500 text-white'
277
+ )}>
278
+ +{availability.startTimes.length - 3}
279
+ </div>
280
+ )}
281
+ </div>
282
+ ) : (
283
+ /* Fallback: Low Availability Badge (when no start times) */
284
+ availability.totalVacancies !== undefined && availability.totalVacancies < 5 && (
285
+ <div className={cn(
286
+ 'text-[10px] font-semibold px-2 py-1 rounded flex items-center gap-0.5 justify-center',
287
+ isSelected
288
+ ? 'bg-red-500/30 text-white border border-red-400/50'
289
+ : 'bg-red-100 text-red-700 border border-red-300'
290
+ )}>
291
+ <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
292
+ <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
293
+ </svg>
294
+ {t('calendar.left', { count: availability.totalVacancies ?? 0 })}
295
+ </div>
296
+ )
297
+ )}
298
+
299
+ {/* Discount Badge */}
300
+ {availability.hasDiscount && availability.discountPercent && (availability.totalVacancies === undefined || availability.totalVacancies >= 5) && (
301
+ <div className={cn(
302
+ 'text-[9px] font-semibold px-1 py-0.5 rounded text-center',
303
+ isSelected
304
+ ? 'bg-white/20 text-white'
305
+ : 'bg-red-100 text-red-700'
306
+ )}>
307
+ -{availability.discountPercent}%
308
+ </div>
309
+ )}
310
+ </>
311
+ ) : null}
312
+ </div>
313
+ )}
314
+ </div>
315
+ </button>
316
+ );
317
+ });
318
+
319
+ // ============ Main Calendar Component ============
320
+
321
+ export function Calendar({
322
+ availabilitiesByDate,
323
+ selectedDate,
324
+ onDateSelect,
325
+ timezone,
326
+ earliestDate,
327
+ onVisibleRangeChange,
328
+ currency,
329
+ showCapacity = false,
330
+ }: CalendarProps) {
331
+ const { t } = useTranslations();
332
+ const { locale } = useLocale();
333
+ const dateFnsLocale = dateFnsLocales[locale] || enUS;
334
+
335
+ // Initialize currentStartDate only once on mount, don't reset when earliestDate changes
336
+ // Use a ref to track if we've initialized to prevent reset on prop changes
337
+ const hasInitializedRef = useRef(false);
338
+ const [currentStartDate, setCurrentStartDate] = useState(() => {
339
+ hasInitializedRef.current = true;
340
+ if (earliestDate) {
341
+ return startOfWeek(earliestDate, { weekStartsOn: 0 });
342
+ }
343
+ return startOfWeek(new Date(), { weekStartsOn: 0 });
344
+ });
345
+
346
+ // Only update currentStartDate if earliestDate changes AND we haven't initialized yet
347
+ // This prevents resetting the calendar position when new availabilities are fetched
348
+ useEffect(() => {
349
+ if (!hasInitializedRef.current && earliestDate) {
350
+ const weekStart = startOfWeek(earliestDate, { weekStartsOn: 0 });
351
+ setCurrentStartDate(weekStart);
352
+ hasInitializedRef.current = true;
353
+ }
354
+ }, [earliestDate]);
355
+ const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
356
+
357
+ // Initialize picker month to match the currently visible month in the main calendar
358
+ const [pickerMonth, setPickerMonth] = useState(() => {
359
+ // Start with the month that contains the current start date
360
+ return startOfMonth(currentStartDate);
361
+ });
362
+
363
+ // Update picker month when main calendar navigates to keep them in sync
364
+ useEffect(() => {
365
+ const currentMonth = startOfMonth(currentStartDate);
366
+ // Only update if the month actually changed and it's within the allowed range
367
+ if (currentMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime() &&
368
+ currentMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
369
+ setPickerMonth(currentMonth);
370
+ }
371
+ }, [currentStartDate]);
372
+
373
+ // Ensure pickerMonth stays within bounds
374
+ useEffect(() => {
375
+ const currentMonth = startOfMonth(pickerMonth);
376
+ if (currentMonth.getTime() < MINI_CALENDAR_START_MONTH.getTime()) {
377
+ setPickerMonth(MINI_CALENDAR_START_MONTH);
378
+ } else if (currentMonth.getTime() > MINI_CALENDAR_END_MONTH.getTime()) {
379
+ setPickerMonth(MINI_CALENDAR_END_MONTH);
380
+ }
381
+ }, [pickerMonth]);
382
+ const datePickerRef = useRef<HTMLDivElement>(null); // Ref for the popup
383
+ const datePickerTriggerRef = useRef<HTMLButtonElement>(null); // Ref for the trigger button
384
+ const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
385
+
386
+ // Get all available dates for dropdown (must be defined before useMemo hooks that use it)
387
+ const availableDates = useMemo(() => {
388
+ return Object.keys(availabilitiesByDate)
389
+ .filter(dateStr => {
390
+ const availabilities = availabilitiesByDate[dateStr];
391
+ return availabilities && availabilities.length > 0 && !availabilities.every(avail => avail.vacancies === 0);
392
+ })
393
+ .sort()
394
+ .map(dateStr => {
395
+ try {
396
+ const [year, month, day] = dateStr.split('-').map(Number);
397
+ return new Date(year, month - 1, day);
398
+ } catch {
399
+ return null;
400
+ }
401
+ })
402
+ .filter((date): date is Date => date !== null);
403
+ }, [availabilitiesByDate]);
404
+
405
+ // Check if we can navigate months in mini calendar (hardcoded to June-October 2026)
406
+ const canNavigatePickerMonthBack = useMemo(() => {
407
+ const currentMonthTime = startOfMonth(pickerMonth).getTime();
408
+ const startMonthTime = MINI_CALENDAR_START_MONTH.getTime();
409
+ return currentMonthTime > startMonthTime;
410
+ }, [pickerMonth]);
411
+
412
+ const canNavigatePickerMonthForward = useMemo(() => {
413
+ const currentMonthTime = startOfMonth(pickerMonth).getTime();
414
+ const endMonthTime = MINI_CALENDAR_END_MONTH.getTime();
415
+ return currentMonthTime < endMonthTime;
416
+ }, [pickerMonth]);
417
+
418
+ // Generate mini calendar days - memoized to avoid recalculation
419
+ const miniCalendarData = useMemo(() => {
420
+ const monthStart = startOfMonth(pickerMonth);
421
+ const monthEnd = endOfMonth(pickerMonth);
422
+ const calendarStart = startOfWeek(monthStart, { weekStartsOn: 0 });
423
+ const calendarEnd = startOfWeek(monthEnd, { weekStartsOn: 0 });
424
+ // Show 6 weeks (42 days) to ensure full calendar grid
425
+ const days = eachDayOfInterval({ start: calendarStart, end: addDays(calendarEnd, 6) });
426
+ return { days, monthStart, monthEnd };
427
+ }, [pickerMonth]);
428
+
429
+ // Calculate position for date picker and close when clicking outside
430
+ useEffect(() => {
431
+ if (!isDatePickerOpen) {
432
+ setPickerPosition(null);
433
+ return;
434
+ }
435
+
436
+ // Calculate position from trigger button
437
+ if (datePickerTriggerRef.current) {
438
+ const rect = datePickerTriggerRef.current.getBoundingClientRect();
439
+ setPickerPosition({
440
+ top: rect.bottom + window.scrollY + 4,
441
+ left: rect.left + window.scrollX,
442
+ });
443
+ }
444
+
445
+ const handleClickOutside = (event: MouseEvent) => {
446
+ const target = event.target as Node;
447
+ // Check if click is outside both the popup and the trigger button
448
+ const isOutsidePopup = datePickerRef.current && !datePickerRef.current.contains(target);
449
+ const isOutsideTrigger = datePickerTriggerRef.current && !datePickerTriggerRef.current.contains(target);
450
+
451
+ if (isOutsidePopup && isOutsideTrigger) {
452
+ setIsDatePickerOpen(false);
453
+ setPickerPosition(null);
454
+ }
455
+ };
456
+
457
+ // Use a small delay to avoid immediate closure when opening
458
+ const timeoutId = setTimeout(() => {
459
+ document.addEventListener('click', handleClickOutside, true);
460
+ }, 100);
461
+
462
+ return () => {
463
+ clearTimeout(timeoutId);
464
+ document.removeEventListener('click', handleClickOutside, true);
465
+ };
466
+ }, [isDatePickerOpen]);
467
+
468
+ // Check if we can navigate backwards/forwards
469
+ const canNavigateBack = useMemo(() => {
470
+ if (availableDates.length === 0) return false;
471
+ const firstAvailableDate = availableDates[0];
472
+ return firstAvailableDate < currentStartDate;
473
+ }, [availableDates, currentStartDate]);
474
+
475
+ const canNavigateForward = useMemo(() => {
476
+ if (availableDates.length === 0) return false;
477
+ const lastAvailableDate = availableDates[availableDates.length - 1];
478
+ const currentEndDate = addDays(currentStartDate, WEEKS_TO_SHOW * 7 - 1);
479
+ return lastAvailableDate > currentEndDate;
480
+ }, [availableDates, currentStartDate]);
481
+
482
+ const handleDateJump = useCallback((date: Date) => {
483
+ const weekStart = startOfWeek(date, { weekStartsOn: 0 });
484
+ const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
485
+
486
+ // Calculate the visible range directly without creating unnecessary array
487
+ const visibleStart = weekStart;
488
+ const visibleEnd = addDays(weekStart, WEEKS_TO_SHOW * 7 - 1);
489
+ const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
490
+ const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
491
+
492
+ // Clear any pending debounced callbacks
493
+ if (debounceTimeoutRef.current) {
494
+ clearTimeout(debounceTimeoutRef.current);
495
+ debounceTimeoutRef.current = null;
496
+ }
497
+
498
+ // Update the ref BEFORE calling the callback to prevent the useEffect from calling it again
499
+ lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
500
+
501
+ // Update state and immediately notify parent (bypass debounce for date jumps)
502
+ setCurrentStartDate(weekStart);
503
+ if (onVisibleRangeChange) {
504
+ onVisibleRangeChange(bufferStart, bufferEnd);
505
+ }
506
+
507
+ onDateSelect(dateStr);
508
+ }, [timezone, onVisibleRangeChange, onDateSelect]);
509
+
510
+ // Generate calendar days (2 weeks = 14 days)
511
+ const calendarDays = useMemo(() => {
512
+ const days: Date[] = [];
513
+ for (let i = 0; i < WEEKS_TO_SHOW * 7; i++) {
514
+ days.push(addDays(currentStartDate, i));
515
+ }
516
+ return days;
517
+ }, [currentStartDate]);
518
+
519
+ // Notify parent of visible date range changes (with buffer weeks before/after)
520
+ // Debounce to avoid excessive API calls during rapid navigation
521
+ const lastReportedRangeRef = useRef<{ start: Date; end: Date } | null>(null);
522
+ const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
523
+
524
+ useEffect(() => {
525
+ if (!onVisibleRangeChange || calendarDays.length === 0) return;
526
+
527
+ const visibleStart = calendarDays[0];
528
+ const visibleEnd = calendarDays[calendarDays.length - 1];
529
+ // Add buffer weeks before and after
530
+ const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
531
+ const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
532
+
533
+ // Only report if range actually changed (more than a day difference)
534
+ const currentReported = lastReportedRangeRef.current;
535
+ if (!currentReported ||
536
+ Math.abs(bufferStart.getTime() - currentReported.start.getTime()) > 24 * 60 * 60 * 1000 ||
537
+ Math.abs(bufferEnd.getTime() - currentReported.end.getTime()) > 24 * 60 * 60 * 1000) {
538
+
539
+ // Clear existing timeout
540
+ if (debounceTimeoutRef.current) {
541
+ clearTimeout(debounceTimeoutRef.current);
542
+ }
543
+
544
+ // Debounce the callback
545
+ debounceTimeoutRef.current = setTimeout(() => {
546
+ lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
547
+ onVisibleRangeChange(bufferStart, bufferEnd);
548
+ }, 300); // 300ms debounce
549
+ }
550
+
551
+ return () => {
552
+ if (debounceTimeoutRef.current) {
553
+ clearTimeout(debounceTimeoutRef.current);
554
+ }
555
+ };
556
+ }, [calendarDays, onVisibleRangeChange]);
557
+
558
+ // Calculate date availability info
559
+ // Use plain object instead of Map so React can detect changes properly
560
+ const dateAvailabilityMap = useMemo(() => {
561
+ const map: Record<string, DateAvailability> = {};
562
+
563
+ Object.entries(availabilitiesByDate).forEach(([dateStr, availabilities]) => {
564
+ if (availabilities && availabilities.length > 0) {
565
+ // Calculate min price from ADULT prices only (for "from" price display)
566
+ // Priority: pricesByCategory.retailPrices (in cents) > rates.price (in dollars)
567
+ const adultPrices = availabilities.flatMap(avail => {
568
+ // First try pricesByCategory.retailPrices (preferred format, in cents)
569
+ if (avail.pricesByCategory?.retailPrices) {
570
+ const adultPrice = avail.pricesByCategory.retailPrices.find(p => p.category === 'ADULT');
571
+ return adultPrice ? [adultPrice.price / 100] : []; // Convert cents to dollars
572
+ }
573
+ // Fallback to rates.price (already in dollars)
574
+ if (avail.rates) {
575
+ const adultRate = avail.rates.find(rate => rate.category === 'ADULT');
576
+ return adultRate && adultRate.price !== undefined && adultRate.price !== null
577
+ ? [adultRate.price]
578
+ : [];
579
+ }
580
+ return [];
581
+ });
582
+
583
+ const minPrice = adultPrices.length > 0 ? Math.min(...adultPrices) : undefined;
584
+
585
+ // Check if sold out (all availabilities have 0 vacancies)
586
+ const isSoldOut = availabilities.every(avail => avail.vacancies === 0);
587
+
588
+ // Calculate total vacancies across all availabilities for this date
589
+ const totalVacancies = availabilities.reduce((sum, avail) => sum + avail.vacancies, 0);
590
+
591
+ // Extract start times from availabilities and track which are sold out
592
+ const timeAvailabilityMap = new Map<string, { isSoldOut: boolean; vacancies: number }>();
593
+ const timeCapacityMap: Record<string, { booked: number; total: number }> = {};
594
+
595
+ availabilities.forEach(avail => {
596
+ let timeStr: string | null = null;
597
+ try {
598
+ // Parse ISO datetime and extract time portion
599
+ const dateTime = parseISO(avail.dateTime);
600
+ timeStr = formatInTimeZone(dateTime, timezone, 'HH:mm');
601
+ } catch {
602
+ const timeMatch = avail.dateTime.match(/T(\d{2}:\d{2})/);
603
+ if (timeMatch) timeStr = timeMatch[1];
604
+ }
605
+ if (timeStr) {
606
+ const existing = timeAvailabilityMap.get(timeStr);
607
+ if (existing) {
608
+ timeAvailabilityMap.set(timeStr, {
609
+ isSoldOut: existing.isSoldOut && avail.vacancies === 0,
610
+ vacancies: existing.vacancies + avail.vacancies
611
+ });
612
+ } else {
613
+ timeAvailabilityMap.set(timeStr, {
614
+ isSoldOut: avail.vacancies === 0,
615
+ vacancies: avail.vacancies
616
+ });
617
+ }
618
+ // Per-time capacity for admin: first availability for this time wins
619
+ const totalCapacity = avail.totalCapacity ?? 0;
620
+ if (totalCapacity > 0 && !timeCapacityMap[timeStr]) {
621
+ const booked = avail.bookedCapacity ?? (totalCapacity - avail.vacancies);
622
+ timeCapacityMap[timeStr] = { booked, total: totalCapacity };
623
+ }
624
+ }
625
+ });
626
+
627
+ const startTimes = Array.from(timeAvailabilityMap.keys()).sort();
628
+ const soldOutTimes = new Set(
629
+ Array.from(timeAvailabilityMap.entries())
630
+ .filter(([, info]) => info.isSoldOut)
631
+ .map(([time]) => time)
632
+ );
633
+
634
+ // Calculate total discount percentage from all negative adjustments
635
+ const totalDiscountPercent = calculateTotalDiscountPercent(availabilities, currency);
636
+
637
+ map[dateStr] = {
638
+ date: dateStr,
639
+ availabilityCount: availabilities.length,
640
+ isSoldOut,
641
+ minPrice,
642
+ totalVacancies,
643
+ startTimes: startTimes.length > 0 ? startTimes : undefined,
644
+ soldOutTimes: soldOutTimes.size > 0 ? soldOutTimes : undefined,
645
+ totalDiscountPercent,
646
+ timeCapacityMap: Object.keys(timeCapacityMap).length > 0 ? timeCapacityMap : undefined,
647
+ };
648
+ }
649
+ });
650
+
651
+ return map;
652
+ }, [availabilitiesByDate, timezone, currency]);
653
+
654
+ // Get display range for header
655
+ const displayRange = useMemo(() => {
656
+ const firstDay = calendarDays[0];
657
+ const lastDay = calendarDays[calendarDays.length - 1];
658
+ return {
659
+ start: formatInTimeZone(firstDay, timezone, 'MMM d', { locale: dateFnsLocale }),
660
+ end: formatInTimeZone(lastDay, timezone, 'MMM d, yyyy', { locale: dateFnsLocale }),
661
+ };
662
+ }, [calendarDays, timezone, dateFnsLocale]);
663
+
664
+ // Navigation handlers
665
+ const handlePrevious = () => {
666
+ setCurrentStartDate(subWeeks(currentStartDate, WEEKS_TO_SHOW));
667
+ };
668
+
669
+ const handleNext = () => {
670
+ setCurrentStartDate(addWeeks(currentStartDate, WEEKS_TO_SHOW));
671
+ };
672
+
673
+ const handleDateClick = (date: Date) => {
674
+ const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
675
+ const availability = dateAvailabilityMap[dateStr] || null;
676
+
677
+ // Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking
678
+ if (availability && (!availability.isSoldOut || showCapacity)) {
679
+ onDateSelect(dateStr);
680
+ }
681
+ };
682
+
683
+ return (
684
+ <div className="w-full max-w-2xl mx-auto relative">
685
+ {/* Calendar Header with Navigation */}
686
+ <div className="flex items-center justify-between mb-1.5">
687
+ <button
688
+ onClick={handlePrevious}
689
+ disabled={!canNavigateBack}
690
+ className="p-1.5 rounded-lg hover:bg-stone-100 transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
691
+ aria-label={t('calendar.previousWeeks')}
692
+ >
693
+ <svg
694
+ className="w-4 h-4 text-stone-600"
695
+ fill="none"
696
+ stroke="currentColor"
697
+ viewBox="0 0 24 24"
698
+ >
699
+ <path
700
+ strokeLinecap="round"
701
+ strokeLinejoin="round"
702
+ strokeWidth={2}
703
+ d="M15 19l-7-7 7-7"
704
+ />
705
+ </svg>
706
+ </button>
707
+ <div className="relative">
708
+ <button
709
+ ref={datePickerTriggerRef}
710
+ onClick={() => setIsDatePickerOpen(!isDatePickerOpen)}
711
+ className="text-xs font-medium text-stone-700 hover:text-stone-900 px-2 py-1 rounded hover:bg-stone-100 transition-colors cursor-pointer flex items-center gap-1"
712
+ >
713
+ <span>{displayRange.start} - {displayRange.end}</span>
714
+ <svg
715
+ className={`w-3 h-3 text-stone-600 transition-transform ${isDatePickerOpen ? 'rotate-180' : ''}`}
716
+ fill="none"
717
+ stroke="currentColor"
718
+ viewBox="0 0 24 24"
719
+ >
720
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
721
+ </svg>
722
+ </button>
723
+ {isDatePickerOpen && pickerPosition && (
724
+ <div
725
+ ref={datePickerRef}
726
+ className="fixed bg-white border border-stone-300 rounded-lg shadow-lg z-50 p-3 min-w-[280px] max-h-[400px] overflow-y-auto"
727
+ style={{
728
+ top: `${pickerPosition.top}px`,
729
+ left: `${pickerPosition.left}px`,
730
+ }}
731
+ onClick={(e) => e.stopPropagation()}
732
+ >
733
+ {/* Mini Calendar Header */}
734
+ <div className="flex items-center justify-between mb-2">
735
+ <button
736
+ type="button"
737
+ onClick={(e) => {
738
+ e.preventDefault();
739
+ e.stopPropagation();
740
+ if (!canNavigatePickerMonthBack) return;
741
+ const previousMonth = startOfMonth(subMonths(pickerMonth, 1));
742
+ // Clamp to June-October 2026 range
743
+ if (previousMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime()) {
744
+ setPickerMonth(previousMonth);
745
+ }
746
+ }}
747
+ disabled={!canNavigatePickerMonthBack}
748
+ className="p-1 rounded hover:bg-stone-100 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
749
+ >
750
+ <svg className="w-4 h-4 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
751
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
752
+ </svg>
753
+ </button>
754
+ <div className="text-sm font-semibold text-stone-700">
755
+ {formatInTimeZone(pickerMonth, timezone, 'MMMM yyyy', { locale: dateFnsLocale })}
756
+ </div>
757
+ <button
758
+ type="button"
759
+ onClick={(e) => {
760
+ e.preventDefault();
761
+ e.stopPropagation();
762
+ if (!canNavigatePickerMonthForward) return;
763
+ const nextMonth = startOfMonth(addMonths(pickerMonth, 1));
764
+ // Clamp to June-October 2026 range
765
+ if (nextMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
766
+ setPickerMonth(nextMonth);
767
+ }
768
+ }}
769
+ disabled={!canNavigatePickerMonthForward}
770
+ className="p-1 rounded hover:bg-stone-100 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
771
+ >
772
+ <svg className="w-4 h-4 text-stone-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
773
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
774
+ </svg>
775
+ </button>
776
+ </div>
777
+
778
+ {/* Mini Calendar Days of Week */}
779
+ <div className="grid grid-cols-7 gap-1 mb-1">
780
+ {DAYS_OF_WEEK_KEYS.map((dayKey) => (
781
+ <div key={dayKey} className="text-[10px] font-semibold text-stone-500 text-center py-1">
782
+ {t(`calendar.days.${dayKey}`)[0]}
783
+ </div>
784
+ ))}
785
+ </div>
786
+
787
+ {/* Mini Calendar Grid */}
788
+ <div className="grid grid-cols-7 gap-1">
789
+ {miniCalendarData.days.map((day) => {
790
+ const dateStr = formatInTimeZone(day, timezone, 'yyyy-MM-dd');
791
+ const dayNumber = formatInTimeZone(day, timezone, 'd');
792
+ const availability = dateAvailabilityMap[dateStr] || null;
793
+ const isSelected = selectedDate === dateStr;
794
+ const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
795
+ // Check if day is within allowed range (June 1 - October 12, 2026)
796
+ const isInAllowedRange = day >= MINI_CALENDAR_START_DATE && day <= MINI_CALENDAR_END_DATE;
797
+
798
+ // If we have availability data and it's sold out, show as sold out
799
+ // Otherwise, if in allowed range, show as available (even if not fetched yet)
800
+ const isSoldOut = availability?.isSoldOut === true;
801
+ // When showCapacity (admin), sold-out days are still selectable for overbooking
802
+ const isAvailable = isInAllowedRange && (!isSoldOut || showCapacity);
803
+ const isToday = isSameDay(day, new Date());
804
+
805
+ return (
806
+ <button
807
+ key={dateStr}
808
+ onClick={() => {
809
+ if (isAvailable) {
810
+ handleDateJump(day);
811
+ setIsDatePickerOpen(false);
812
+ }
813
+ }}
814
+ disabled={!isAvailable}
815
+ className={cn(
816
+ 'aspect-square text-xs rounded transition-colors',
817
+ !isCurrentMonth && 'text-stone-300',
818
+ isSoldOut && !showCapacity
819
+ ? 'bg-red-400 text-white cursor-not-allowed'
820
+ : isSoldOut && showCapacity
821
+ ? 'bg-red-400 text-white cursor-pointer hover:bg-red-500'
822
+ : !isInAllowedRange
823
+ ? 'text-stone-300 cursor-not-allowed'
824
+ : isSelected
825
+ ? 'bg-emerald-600 text-white hover:bg-emerald-700'
826
+ : 'bg-emerald-50 text-stone-900 hover:bg-emerald-100 cursor-pointer',
827
+ isToday && isAvailable && !isSelected && 'ring-1 ring-emerald-500'
828
+ )}
829
+ >
830
+ {dayNumber}
831
+ </button>
832
+ );
833
+ })}
834
+ </div>
835
+ </div>
836
+ )}
837
+ </div>
838
+ <button
839
+ onClick={handleNext}
840
+ disabled={!canNavigateForward}
841
+ className="p-1.5 rounded-lg hover:bg-stone-100 transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent"
842
+ aria-label={t('calendar.nextWeeks')}
843
+ >
844
+ <svg
845
+ className="w-4 h-4 text-stone-600"
846
+ fill="none"
847
+ stroke="currentColor"
848
+ viewBox="0 0 24 24"
849
+ >
850
+ <path
851
+ strokeLinecap="round"
852
+ strokeLinejoin="round"
853
+ strokeWidth={2}
854
+ d="M9 5l7 7-7 7"
855
+ />
856
+ </svg>
857
+ </button>
858
+ </div>
859
+
860
+ {/* Calendar Grid: on mobile scroll horizontally; on sm+ fit container so no scroll to see Saturday */}
861
+ <div className="border border-stone-200 rounded-lg overflow-hidden bg-white overflow-x-auto sm:overflow-visible">
862
+ <div className="min-w-[700px] sm:min-w-0 sm:w-full pb-3 sm:pb-0">
863
+ {/* Day Headers */}
864
+ <div className="grid grid-cols-7 bg-stone-50 border-b border-stone-200">
865
+ {DAYS_OF_WEEK_KEYS.map((dayKey) => (
866
+ <div
867
+ key={dayKey}
868
+ className="py-1 text-center text-xs font-semibold text-stone-600 uppercase tracking-wide"
869
+ >
870
+ {t(`calendar.days.${dayKey}`)}
871
+ </div>
872
+ ))}
873
+ </div>
874
+
875
+ {/* Calendar Days Grid - row height = tallest cell in row; all cells stretch to that height */}
876
+ <div className="grid grid-cols-7 items-stretch">
877
+ {calendarDays.map((date) => {
878
+ const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
879
+ const availability = dateAvailabilityMap[dateStr] || null;
880
+ const isSelected = selectedDate === dateStr;
881
+ const isToday = isSameDay(date, new Date());
882
+
883
+ // Include availability data in key to force re-render when availability changes
884
+ const availabilityKey = availability
885
+ ? `${availability.availabilityCount}-${availability.isSoldOut}-${availability.startTimes?.join(',') || ''}`
886
+ : 'none';
887
+ return (
888
+ <DateCell
889
+ key={`${dateStr}-${availabilityKey}`}
890
+ date={date}
891
+ dateStr={dateStr}
892
+ availability={availability}
893
+ isSelected={isSelected}
894
+ isToday={isToday}
895
+ showCapacity={showCapacity}
896
+ timezone={timezone}
897
+ onClick={() => handleDateClick(date)}
898
+ />
899
+ );
900
+ })}
901
+ </div>
902
+ </div>
903
+ </div>
904
+ </div>
905
+ );
906
+ }