@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0

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 (158) hide show
  1. package/package.json +29 -2
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/BookingWidget.tsx +302 -0
  9. package/src/components/ManageBookingView.tsx +437 -0
  10. package/src/components/PhoneInputWithCountry.module.css +131 -0
  11. package/src/components/PhoneInputWithCountry.tsx +44 -0
  12. package/src/components/PickupLocationDialog.module.css +360 -0
  13. package/src/components/PickupLocationDialog.tsx +357 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/booking/AddOnsSection.module.css +10 -0
  17. package/src/components/booking/AddOnsSection.tsx +184 -0
  18. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  19. package/src/components/booking/BookingDialog.module.css +643 -0
  20. package/src/components/booking/BookingDialog.tsx +356 -0
  21. package/src/components/booking/BookingFlow.tsx +4385 -0
  22. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  23. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  24. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  25. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  26. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  27. package/src/components/booking/BookingProductGrid.module.css +359 -0
  28. package/src/components/booking/BookingProductGrid.tsx +497 -0
  29. package/src/components/booking/Calendar.module.css +616 -0
  30. package/src/components/booking/Calendar.tsx +1123 -0
  31. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  32. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  33. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  34. package/src/components/booking/CheckoutForm.module.css +244 -0
  35. package/src/components/booking/CheckoutForm.tsx +364 -0
  36. package/src/components/booking/CheckoutModal.tsx +451 -0
  37. package/src/components/booking/CurrencySwitcher.tsx +81 -0
  38. package/src/components/booking/DapFlowCollage.tsx +88 -0
  39. package/src/components/booking/DapTourDescription.tsx +35 -0
  40. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  41. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  42. package/src/components/booking/ErrorBoundary.tsx +63 -0
  43. package/src/components/booking/InfoTooltip.tsx +108 -0
  44. package/src/components/booking/ItineraryBox.module.css +258 -0
  45. package/src/components/booking/ItineraryBox.tsx +550 -0
  46. package/src/components/booking/ItineraryBuilder.tsx +82 -0
  47. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  48. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  49. package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
  50. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  51. package/src/components/booking/PickupLocationSelector.tsx +1566 -0
  52. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  53. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  54. package/src/components/booking/PriceBreakdown.tsx +154 -0
  55. package/src/components/booking/PriceSummary.tsx +234 -0
  56. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  57. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  58. package/src/components/booking/PromoCodeInput.module.css +166 -0
  59. package/src/components/booking/PromoCodeInput.tsx +99 -0
  60. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  61. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  62. package/src/components/booking/TermsAcceptance.tsx +111 -0
  63. package/src/components/booking/TicketSelector.module.css +164 -0
  64. package/src/components/booking/TicketSelector.tsx +199 -0
  65. package/src/components/booking/TourDescription.module.css +304 -0
  66. package/src/components/booking/TourDescription.tsx +273 -0
  67. package/src/components/booking/booking-flow-ui.ts +38 -0
  68. package/src/components/booking/booking-flow.css +944 -0
  69. package/src/components/button.css +245 -0
  70. package/src/components/button.tsx +152 -0
  71. package/src/components/colorable-svg.tsx +29 -0
  72. package/src/components/image.css +29 -0
  73. package/src/components/image.tsx +113 -0
  74. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  75. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  76. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
  77. package/src/components/product-tag.module.css +30 -0
  78. package/src/components/product-tag.tsx +34 -0
  79. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  80. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  81. package/src/components/terms/TermsContent.tsx +178 -0
  82. package/src/components/value-pill.module.css +59 -0
  83. package/src/components/value-pill.tsx +46 -0
  84. package/src/constants/images.ts +556 -0
  85. package/src/constants/pill-values.ts +210 -0
  86. package/src/constants/products.ts +155 -0
  87. package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
  88. package/src/contexts/BookingAppContext.tsx +134 -0
  89. package/src/contexts/CompanyContext.tsx +70 -0
  90. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  91. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  92. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  93. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  94. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  95. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  96. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  97. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  98. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  99. package/src/data/product-descriptions/private-tour.en.json +80 -0
  100. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  101. package/src/data/products-config.json +101 -0
  102. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  103. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  104. package/src/index.ts +79 -0
  105. package/src/lib/analytics.ts +197 -0
  106. package/src/lib/booking/booking-source.ts +51 -0
  107. package/src/lib/booking/checkout-breakdown.ts +69 -0
  108. package/src/lib/booking/correlation-id.ts +46 -0
  109. package/src/lib/booking/i18n/config.ts +21 -0
  110. package/src/lib/booking/i18n/index.tsx +144 -0
  111. package/src/lib/booking/i18n/messages/en.json +236 -0
  112. package/src/lib/booking/i18n/messages/fr.json +236 -0
  113. package/src/lib/booking/itinerary-display.ts +36 -0
  114. package/src/lib/booking/itinerary-labels.ts +70 -0
  115. package/src/lib/booking/location-calculations.ts +43 -0
  116. package/src/lib/booking/location-utils.ts +165 -0
  117. package/src/lib/booking/map-utils.ts +153 -0
  118. package/src/lib/booking/marker-icons.ts +113 -0
  119. package/src/lib/booking/normalize-booking-product-id.ts +21 -0
  120. package/src/lib/booking/pickup-location-types.ts +25 -0
  121. package/src/lib/booking/places-api.ts +154 -0
  122. package/src/lib/booking/pricing.ts +466 -0
  123. package/src/lib/booking/product-option-id.ts +35 -0
  124. package/src/lib/booking/source-metadata.ts +226 -0
  125. package/src/lib/booking/sunday-week.ts +14 -0
  126. package/src/lib/booking/theme.ts +83 -0
  127. package/src/lib/booking/trace-context.ts +62 -0
  128. package/src/lib/booking/utils.ts +9 -0
  129. package/src/lib/booking-api.ts +1793 -0
  130. package/src/lib/booking-constants.ts +23 -0
  131. package/src/lib/booking-ref.ts +13 -0
  132. package/src/lib/booking-types.ts +36 -0
  133. package/src/lib/currency.ts +81 -0
  134. package/src/lib/dap-descriptions.ts +50 -0
  135. package/src/lib/dap-itinerary-preview.ts +315 -0
  136. package/src/lib/dependent-add-on-api.ts +434 -0
  137. package/src/lib/env.ts +96 -0
  138. package/src/lib/firebase.ts +20 -0
  139. package/src/lib/job-application-api.ts +83 -0
  140. package/src/lib/manage-booking-embed-print.ts +16 -0
  141. package/src/lib/manage-booking-post-checkout.ts +68 -0
  142. package/src/lib/photo-dap-config.ts +228 -0
  143. package/src/lib/photo-packages.ts +75 -0
  144. package/src/lib/pickup/map-utils.ts +56 -0
  145. package/src/lib/pickup/marker-icons.ts +19 -0
  146. package/src/lib/product-descriptions.ts +66 -0
  147. package/src/lib/products-config.ts +73 -0
  148. package/src/providers/booking-dialog-provider.tsx +282 -0
  149. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  150. package/src/radius.css +5 -0
  151. package/src/spacing.css +7 -0
  152. package/src/strings/en.json +1774 -0
  153. package/src/strings/es.json +1573 -0
  154. package/src/strings/fr.json +1573 -0
  155. package/src/strings/index.js +23 -0
  156. package/src/text-style.css +56 -0
  157. package/src/utils/currency-converter.ts +101 -0
  158. package/tsconfig.json +8 -2
@@ -0,0 +1,1123 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react';
4
+ import { createPortal } from 'react-dom';
5
+ import { startOfWeek, addDays, addWeeks, subWeeks, isSameDay, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths } from 'date-fns';
6
+ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
7
+ import { enUS, fr } from 'date-fns/locale';
8
+ import type { Availability } from '@/lib/booking-api';
9
+ import { useTranslations, useLocale } from '@/lib/booking/i18n';
10
+ import {
11
+ MINI_CALENDAR_START_MONTH,
12
+ MINI_CALENDAR_END_MONTH,
13
+ VISIBLE_RANGE_BUFFER_WEEKS,
14
+ } from '@/lib/booking-constants';
15
+ import { getSundayOfWeek } from '@/lib/booking/sunday-week';
16
+ import { cn } from '@/lib/booking/utils';
17
+ import styles from './Calendar.module.css';
18
+
19
+ // ============ Types ============
20
+
21
+ export interface DateAvailability {
22
+ date: string; // yyyy-MM-dd format
23
+ availabilityCount: number;
24
+ isSoldOut: boolean;
25
+ minPrice?: number;
26
+ hasDiscount?: boolean;
27
+ discountPercent?: number;
28
+ totalVacancies?: number; // Total available tickets across all availabilities for this date
29
+ startTimes?: string[]; // Array of start times for this date (e.g., ["09:00", "10:00", "14:00"])
30
+ soldOutTimes?: Set<string>; // Set of sold out time strings (e.g., ["09:00", "14:00"])
31
+ totalDiscountPercent?: number; // Total discount percentage from all negative adjustments (deals + dynamic pricing)
32
+ /** Per-time capacity (admin): vacancies by default; booked/total only when the slot is sold out */
33
+ timeCapacityMap?: Record<string, { booked: number; total: number; vacancies: number }>;
34
+ }
35
+
36
+ interface CalendarProps {
37
+ availabilitiesByDate: Record<string, Availability[]>;
38
+ selectedDate: string | null;
39
+ onDateSelect: (date: string) => void;
40
+ timezone: string;
41
+ earliestDate: Date | null;
42
+ onVisibleRangeChange?: (startDate: Date, endDate: Date) => void;
43
+ currency: string; // Currency code (e.g., "CAD", "USD") for discount calculations
44
+ /** When true (admin), show capacity on each time slot (vacancies unless sold out) */
45
+ showCapacity?: boolean;
46
+ /** When true, show loading spinner in the date dropdown (e.g. when fetching availability for a new month) */
47
+ isLoading?: boolean;
48
+ /** Controls date-cell content style: detailed times vs simple availability status */
49
+ displayMode?: 'times' | 'status';
50
+ /** Optional extra discount percent added to the day-level discount badge. */
51
+ extraDiscountPercent?: number;
52
+ /** When true, cap day discount badges to the currently selected date's discount percent. */
53
+ capDiscountToSelectedDate?: boolean;
54
+ /**
55
+ * When true (e.g. change-booking flow), scroll the visible grid once to the week containing
56
+ * `selectedDate` after the parent sets it from the current booking. Otherwise the grid stays
57
+ * anchored to the earliest available date.
58
+ */
59
+ syncVisibleWeekToSelectedDate?: boolean;
60
+ }
61
+
62
+ // ============ Constants ============
63
+
64
+ const DAYS_OF_WEEK_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
65
+ const WEEKS_TO_SHOW_DESKTOP = 2;
66
+ const WEEKS_TO_SHOW_MOBILE = 2;
67
+
68
+ // Date-fns locale map
69
+ const dateFnsLocales = {
70
+ en: enUS,
71
+ fr: fr,
72
+ } as const;
73
+
74
+ // ============ Helper Functions ============
75
+
76
+ /**
77
+ * Get net discount percentage for a date from percentage-type adjustments.
78
+ * - Deals: always discounts (add to total)
79
+ * - Dynamic pricing discounts (negative change): add to total
80
+ * - Dynamic pricing surcharges (positive change): subtract from total
81
+ * Result: discounts minus surcharges. Only shown if net is positive (i.e. net discount).
82
+ *
83
+ * @param availabilities All availabilities for a specific date
84
+ * @param currency Currency code to check changeByCurrency for dynamic pricing
85
+ * @returns Net discount percentage (e.g., 15 for -15% off), or undefined if net <= 0
86
+ */
87
+ function calculateTotalDiscountPercent(
88
+ availabilities: Availability[],
89
+ currency: string
90
+ ): number | undefined {
91
+ // Get the first availability with rates
92
+ const avail = availabilities.find(a => a.rates && a.rates.length > 0);
93
+ if (!avail?.rates) return undefined;
94
+
95
+ // Get ADULT rate adjustments
96
+ const adultRate = avail.rates.find(rate => rate.category === 'ADULT');
97
+ if (!adultRate) return undefined;
98
+
99
+ const adjustments = adultRate.appliedAdjustments || adultRate.applied_adjustments || [];
100
+
101
+ let totalDiscount = 0;
102
+ let totalSurcharge = 0;
103
+ const seenIds = new Set<string>();
104
+
105
+ for (const adj of adjustments) {
106
+ if (seenIds.has(adj.id)) continue;
107
+ if (adj.adjustmentType !== 'percentage' || !adj.adjustmentValue) continue;
108
+
109
+ seenIds.add(adj.id);
110
+
111
+ if (adj.type === 'deal') {
112
+ // Deals are always discounts
113
+ totalDiscount += Math.abs(adj.adjustmentValue);
114
+ } else if (adj.type === 'dynamic') {
115
+ const change = adj.changeByCurrency?.[currency];
116
+ if (change !== undefined) {
117
+ if (change < 0) {
118
+ totalDiscount += Math.abs(adj.adjustmentValue);
119
+ } else {
120
+ totalSurcharge += Math.abs(adj.adjustmentValue);
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ const net = totalDiscount - totalSurcharge;
127
+ return net > 0 ? Math.round(net) : undefined;
128
+ }
129
+
130
+ /**
131
+ * Create a Date representing noon on the given date string in the target timezone.
132
+ * Using noon avoids day-boundary issues when formatting across timezones.
133
+ */
134
+ function dateStrToNoonInTz(dateStr: string, timezone: string): Date {
135
+ return fromZonedTime(parseISO(`${dateStr}T12:00:00`), timezone);
136
+ }
137
+
138
+ function parseAvailabilityDateTime(value: string): Date {
139
+ // If API omits timezone offset, treat it as UTC to prevent user-local shifts.
140
+ const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
141
+ return parseISO(hasExplicitOffset ? value : `${value}Z`);
142
+ }
143
+
144
+ // ============ Date Cell Component ============
145
+
146
+ interface DateCellProps {
147
+ date: Date;
148
+ dateStr: string;
149
+ availability: DateAvailability | null;
150
+ isSelected: boolean;
151
+ isToday: boolean;
152
+ showCapacity?: boolean;
153
+ timezone: string;
154
+ displayMode: 'times' | 'status';
155
+ onClick: () => void;
156
+ isMobile?: boolean;
157
+ }
158
+
159
+ const DateCell = memo(function DateCell({
160
+ date,
161
+ availability,
162
+ isSelected,
163
+ isToday,
164
+ showCapacity,
165
+ timezone,
166
+ displayMode,
167
+ onClick,
168
+ isMobile = false,
169
+ }: DateCellProps) {
170
+ const { t } = useTranslations();
171
+ const { locale } = useLocale();
172
+ const dateFnsLocale = dateFnsLocales[locale] || enUS;
173
+ const dayNumber = formatInTimeZone(date, timezone, 'd');
174
+ // When showCapacity (admin), allow selecting sold-out slots for overbooking
175
+ const hasAvailability = availability !== null && (!availability.isSoldOut || showCapacity);
176
+ const isDisabled = !hasAvailability;
177
+
178
+ // Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
179
+ // Create a Date with those components, interpret as company timezone, then format.
180
+ const formatTime = useCallback((timeStr: string) => {
181
+ try {
182
+ const [hours, minutes] = timeStr.split(':').map(Number);
183
+ const dateWithTime = new Date(2000, 0, 1, hours, minutes, 0);
184
+ const dateInTz = fromZonedTime(dateWithTime, timezone);
185
+ if (locale === 'fr') {
186
+ return formatInTimeZone(dateInTz, timezone, minutes === 0 ? 'HH' : 'HH:mm', { locale: dateFnsLocale });
187
+ }
188
+ return formatInTimeZone(dateInTz, timezone, minutes === 0 ? 'ha' : 'h:mma', { locale: dateFnsLocale });
189
+ } catch {
190
+ return timeStr;
191
+ }
192
+ }, [locale, timezone, dateFnsLocale]);
193
+
194
+ // Two heights: tall when >2 time slots (discount + times); otherwise short. Mobile uses compact dot+time cells.
195
+ // Admin capacity line under each time pill needs extra vertical room (provider dashboard only).
196
+ const slotCount = availability?.startTimes?.length ?? 0;
197
+ const needsTallerCell = !isMobile && slotCount > 2;
198
+ const mobileNeedsTaller = isMobile && slotCount > 2;
199
+ const adminCapacityTallCells =
200
+ Boolean(showCapacity) && !isMobile && slotCount > 0 && displayMode === 'times';
201
+ const buttonClassName = useMemo(() => cn(
202
+ styles.calendarDayCell,
203
+ isMobile && (mobileNeedsTaller ? styles.calendarDayCellMobileTall : styles.calendarDayCellMobile),
204
+ !isMobile && (
205
+ adminCapacityTallCells
206
+ ? needsTallerCell
207
+ ? cn('min-w-0', styles.calendarDayCellWithAdminCapacity, styles.calendarDayCellWithAdminCapacityTall)
208
+ : styles.calendarDayCellWithAdminCapacity
209
+ : needsTallerCell
210
+ ? 'min-w-0 min-h-[7rem]'
211
+ : 'min-h-[6rem]'
212
+ ),
213
+ isDisabled
214
+ ? styles.calendarDayCellDisabled
215
+ : isSelected
216
+ ? styles.calendarDayCellSelected
217
+ : styles.calendarDayCellAvailable,
218
+ isToday && hasAvailability && !isSelected && styles.calendarDayCellToday
219
+ ), [isDisabled, isSelected, isToday, hasAvailability, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
220
+
221
+ const ariaLabel = useMemo(() => {
222
+ const parts = [dayNumber];
223
+ if (availability?.isSoldOut) parts.push(' - Sold out');
224
+ if (availability?.hasDiscount) parts.push(` - ${availability.discountPercent}% off`);
225
+ return parts.join('');
226
+ }, [dayNumber, availability?.isSoldOut, availability?.hasDiscount, availability?.discountPercent]);
227
+
228
+ return (
229
+ <button
230
+ onClick={onClick}
231
+ disabled={isDisabled}
232
+ className={buttonClassName}
233
+ aria-label={ariaLabel}
234
+ >
235
+ <div className={cn(styles.calendarDayCellInner, isMobile && styles.calendarDayCellInnerMobile)}>
236
+ {/* Day Number - on mobile, in a top row with discount below to avoid overlap */}
237
+ <div className={styles.calendarDayNumber}>
238
+ {dayNumber}
239
+ </div>
240
+
241
+ {/* Discount Tag - top-right on desktop; below day number on mobile to avoid overlap */}
242
+ {availability?.totalDiscountPercent && availability.totalDiscountPercent > 0 && (
243
+ <div className={cn(styles.calendarDiscountTag, isMobile && styles.calendarDiscountTagMobile)}>
244
+ <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
245
+ <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" />
246
+ </svg>
247
+ <span>-{availability.totalDiscountPercent}%</span>
248
+ </div>
249
+ )}
250
+
251
+ {/* Availability Indicators - Centered */}
252
+ {availability && (
253
+ <div className="flex flex-col items-center justify-center space-y-0 w-full px-0.5">
254
+ {/* Sold Out Badge - Centered */}
255
+ {availability.isSoldOut ? (
256
+ <div className={styles.calendarSoldOutBadge}>
257
+ {t('calendar.soldOut')}
258
+ </div>
259
+ ) : hasAvailability ? (
260
+ <>
261
+ {/* Mobile: green dot + time (compact). Desktop: full time pills */}
262
+ {displayMode === 'status' ? (
263
+ <div className={styles.calendarTimePill}>
264
+ {t('calendar.available')}
265
+ </div>
266
+ ) : availability.startTimes && availability.startTimes.length > 0 ? (
267
+ isMobile ? (
268
+ <div className={cn(
269
+ styles.calendarMobileTimeList,
270
+ availability.totalDiscountPercent && availability.totalDiscountPercent > 0 && styles.calendarMobileTimeListWithDiscount
271
+ )}>
272
+ {availability.startTimes.slice(0, 3).map((time) => {
273
+ const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
274
+ const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
275
+ const isDailyAvailability = time === '00:00';
276
+ return (
277
+ <div
278
+ key={time}
279
+ className={cn(
280
+ styles.calendarMobileTimeDot,
281
+ isTimeSoldOut && styles.calendarMobileTimeDotSoldOut,
282
+ isLowAvailability && !isTimeSoldOut && styles.calendarMobileTimeDotLow
283
+ )}
284
+ >
285
+ <span className={styles.calendarMobileTimeDotBullet} aria-hidden />
286
+ <span>{isDailyAvailability ? t('calendar.available') : formatTime(time)}</span>
287
+ </div>
288
+ );
289
+ })}
290
+ {availability.startTimes.length > 3 && (
291
+ <div className={styles.calendarMobileTimeDot}>
292
+ <span className={styles.calendarMobileTimeDotBullet} aria-hidden />
293
+ <span>+{availability.startTimes.length - 3}</span>
294
+ </div>
295
+ )}
296
+ </div>
297
+ ) : (
298
+ <div className="flex flex-wrap gap-0.5 justify-center">
299
+ {availability.startTimes.slice(0, 3).map((time) => {
300
+ const isTimeSoldOut = availability.soldOutTimes?.has(time) || false;
301
+ const isLowAvailability = !isTimeSoldOut && availability.totalVacancies !== undefined && availability.totalVacancies < 5;
302
+ const isDailyAvailability = time === '00:00';
303
+ return (
304
+ <div
305
+ key={time}
306
+ className={cn(
307
+ styles.calendarTimePill,
308
+ isTimeSoldOut && styles.calendarTimePillSoldOut,
309
+ isLowAvailability && !isTimeSoldOut && styles.calendarTimePillLow
310
+ )}
311
+ >
312
+ <div>{isDailyAvailability ? t('calendar.available') : formatTime(time)}</div>
313
+ {showCapacity && availability.timeCapacityMap?.[time] && (() => {
314
+ const cap = availability.timeCapacityMap[time];
315
+ const vac = cap.vacancies;
316
+ return (
317
+ <div className="text-[9px] -mt-0.5 tabular-nums opacity-90">
318
+ {isTimeSoldOut
319
+ ? `${cap.booked}/${cap.total}`
320
+ : t('calendar.spotsAvailable', { count: vac })}
321
+ </div>
322
+ );
323
+ })()}
324
+ {isTimeSoldOut ? (
325
+ <div className="text-[9px] -mt-0.5">
326
+ {t('calendar.soldOut')}
327
+ </div>
328
+ ) : isLowAvailability && !showCapacity && (
329
+ <div className="flex items-center gap-0.5 text-[9px] -mt-0.5">
330
+ <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
331
+ <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" />
332
+ </svg>
333
+ <span>{t('calendar.left', { count: availability.totalVacancies ?? 0 })}</span>
334
+ </div>
335
+ )}
336
+ </div>
337
+ );
338
+ })}
339
+ {availability.startTimes.length > 3 && (
340
+ <div className={styles.calendarTimePill}>
341
+ +{availability.startTimes.length - 3}
342
+ </div>
343
+ )}
344
+ </div>
345
+ )
346
+ ) : (
347
+ /* Fallback: Low Availability Badge (when no start times) */
348
+ availability.totalVacancies !== undefined && availability.totalVacancies < 5 && (
349
+ <div className={styles.calendarLowAvailabilityBadge}>
350
+ <svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
351
+ <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" />
352
+ </svg>
353
+ {t('calendar.left', { count: availability.totalVacancies ?? 0 })}
354
+ </div>
355
+ )
356
+ )}
357
+
358
+ {/* Discount Badge - desktop only (mobile uses corner tag) */}
359
+ {!isMobile && availability.hasDiscount && availability.discountPercent && (availability.totalVacancies === undefined || availability.totalVacancies >= 5) && (
360
+ <div className={styles.calendarDiscountBadge}>
361
+ -{availability.discountPercent}%
362
+ </div>
363
+ )}
364
+ </>
365
+ ) : null}
366
+ </div>
367
+ )}
368
+ </div>
369
+ </button>
370
+ );
371
+ });
372
+
373
+ // ============ Main Calendar Component ============
374
+
375
+ export function Calendar({
376
+ availabilitiesByDate,
377
+ selectedDate,
378
+ onDateSelect,
379
+ timezone,
380
+ earliestDate,
381
+ onVisibleRangeChange,
382
+ currency,
383
+ showCapacity = false,
384
+ isLoading = false,
385
+ displayMode = 'times',
386
+ extraDiscountPercent = 0,
387
+ capDiscountToSelectedDate = false,
388
+ syncVisibleWeekToSelectedDate = false,
389
+ }: CalendarProps) {
390
+ const { t } = useTranslations();
391
+ const { locale } = useLocale();
392
+ const dateFnsLocale = dateFnsLocales[locale] || enUS;
393
+
394
+ // Store the Sunday (week start) as a date string in company timezone.
395
+ // This ensures the calendar grid is always correct regardless of user's timezone.
396
+ const hasInitializedRef = useRef(false);
397
+ const [currentStartDateStr, setCurrentStartDateStr] = useState(() => {
398
+ hasInitializedRef.current = true;
399
+ if (earliestDate) {
400
+ const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
401
+ return getSundayOfWeek(dateStr, timezone);
402
+ }
403
+ const nowStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
404
+ return getSundayOfWeek(nowStr, timezone);
405
+ });
406
+
407
+ // Only update if earliestDate changes AND we haven't initialized yet
408
+ useEffect(() => {
409
+ if (!hasInitializedRef.current && earliestDate) {
410
+ const dateStr = formatInTimeZone(earliestDate, timezone, 'yyyy-MM-dd');
411
+ setCurrentStartDateStr(getSundayOfWeek(dateStr, timezone));
412
+ hasInitializedRef.current = true;
413
+ }
414
+ }, [earliestDate, timezone]);
415
+ const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
416
+ const [isMobile, setIsMobile] = useState(false);
417
+
418
+ useEffect(() => {
419
+ const checkMobile = () => setIsMobile(window.innerWidth < 640);
420
+ checkMobile();
421
+ window.addEventListener('resize', checkMobile);
422
+ return () => window.removeEventListener('resize', checkMobile);
423
+ }, []);
424
+
425
+ const WEEKS_TO_SHOW = isMobile ? WEEKS_TO_SHOW_MOBILE : WEEKS_TO_SHOW_DESKTOP;
426
+
427
+ // Initialize picker month to match the currently visible month in the main calendar (in company timezone)
428
+ const [pickerMonth, setPickerMonth] = useState(() => {
429
+ const [y, m] = currentStartDateStr.split('-').map(Number);
430
+ return dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-01`, timezone);
431
+ });
432
+
433
+ // Update picker month when main calendar navigates to keep them in sync
434
+ useEffect(() => {
435
+ const [y, m] = currentStartDateStr.split('-').map(Number);
436
+ const currentMonth = dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-01`, timezone);
437
+ if (currentMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime() &&
438
+ currentMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
439
+ setPickerMonth(currentMonth);
440
+ }
441
+ }, [currentStartDateStr, timezone]);
442
+
443
+ // Ensure pickerMonth stays within bounds
444
+ useEffect(() => {
445
+ const currentMonth = startOfMonth(pickerMonth);
446
+ if (currentMonth.getTime() < MINI_CALENDAR_START_MONTH.getTime()) {
447
+ setPickerMonth(MINI_CALENDAR_START_MONTH);
448
+ } else if (currentMonth.getTime() > MINI_CALENDAR_END_MONTH.getTime()) {
449
+ setPickerMonth(MINI_CALENDAR_END_MONTH);
450
+ }
451
+ }, [pickerMonth]);
452
+ const datePickerRef = useRef<HTMLDivElement>(null); // Ref for the popup
453
+ const datePickerTriggerRef = useRef<HTMLButtonElement>(null); // Ref for the trigger button
454
+ const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number } | null>(null);
455
+
456
+ // Capture the month when dropdown opens (already fetched from main calendar - don't show spinner for it)
457
+ const [initialMonthOnOpen, setInitialMonthOnOpen] = useState<Date | null>(null);
458
+ useEffect(() => {
459
+ if (isDatePickerOpen) {
460
+ setInitialMonthOnOpen((prev) => (prev === null ? pickerMonth : prev));
461
+ } else {
462
+ setInitialMonthOnOpen(null);
463
+ }
464
+ }, [isDatePickerOpen, pickerMonth]);
465
+
466
+ // Only show spinner when loading a month they navigated to (not the initial month which is already fetched)
467
+ const [showSpinnerInDropdown, setShowSpinnerInDropdown] = useState(false);
468
+ const isInitialMonth = initialMonthOnOpen === null || startOfMonth(pickerMonth).getTime() === startOfMonth(initialMonthOnOpen).getTime();
469
+ useEffect(() => {
470
+ if (!isLoading || !isDatePickerOpen || isInitialMonth) {
471
+ setShowSpinnerInDropdown(false);
472
+ return;
473
+ }
474
+ const timer = setTimeout(() => setShowSpinnerInDropdown(true), 400);
475
+ return () => clearTimeout(timer);
476
+ }, [isLoading, isDatePickerOpen, pickerMonth, isInitialMonth]);
477
+
478
+ // Get all available dates for dropdown - use company timezone for consistent navigation worldwide
479
+ const availableDates = useMemo(() => {
480
+ return Object.keys(availabilitiesByDate)
481
+ .filter(dateStr => {
482
+ const availabilities = availabilitiesByDate[dateStr];
483
+ return availabilities && availabilities.length > 0 && !availabilities.every(avail => avail.vacancies === 0);
484
+ })
485
+ .sort()
486
+ .map(dateStr => dateStrToNoonInTz(dateStr, timezone));
487
+ }, [availabilitiesByDate, timezone]);
488
+
489
+ // Check if we can navigate months in mini calendar (hardcoded to June-October 2026)
490
+ const canNavigatePickerMonthBack = useMemo(() => {
491
+ const currentMonthTime = startOfMonth(pickerMonth).getTime();
492
+ const startMonthTime = MINI_CALENDAR_START_MONTH.getTime();
493
+ return currentMonthTime > startMonthTime;
494
+ }, [pickerMonth]);
495
+
496
+ const canNavigatePickerMonthForward = useMemo(() => {
497
+ const currentMonthTime = startOfMonth(pickerMonth).getTime();
498
+ const endMonthTime = MINI_CALENDAR_END_MONTH.getTime();
499
+ return currentMonthTime < endMonthTime;
500
+ }, [pickerMonth]);
501
+
502
+ // Mini calendar allowed range in company timezone (June 1 - Oct 12 inclusive)
503
+ // Must use company TZ - constants use local TZ which breaks for users in other timezones
504
+ const miniCalendarBoundsInTz = useMemo(() => ({
505
+ start: fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), timezone),
506
+ end: fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), timezone),
507
+ }), [timezone]);
508
+
509
+ // Generate mini calendar days in company timezone (same fix as main grid - avoids wrong day-of-week for users in other TZs)
510
+ const miniCalendarData = useMemo(() => {
511
+ const yearMonth = formatInTimeZone(pickerMonth, timezone, 'yyyy-MM');
512
+ const [y, m] = yearMonth.split('-').map(Number);
513
+ const firstOfMonthStr = `${y}-${String(m).padStart(2, '0')}-01`;
514
+ const sundayStr = getSundayOfWeek(firstOfMonthStr, timezone);
515
+ const sundayDate = dateStrToNoonInTz(sundayStr, timezone);
516
+ const days: Date[] = [];
517
+ for (let i = 0; i < 42; i++) {
518
+ days.push(addDays(sundayDate, i));
519
+ }
520
+ const monthStart = dateStrToNoonInTz(firstOfMonthStr, timezone);
521
+ const lastDay = new Date(y, m, 0).getDate();
522
+ const monthEnd = dateStrToNoonInTz(`${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`, timezone);
523
+ return { days, monthStart, monthEnd };
524
+ }, [pickerMonth, timezone]);
525
+
526
+ // Calculate position for date picker and close when clicking outside
527
+ const PICKER_MIN_WIDTH = 280;
528
+ const PICKER_MAX_HEIGHT = 400;
529
+ const MOBILE_BREAKPOINT = 640;
530
+ const VIEWPORT_PADDING = 16;
531
+ const VIEWPORT_PADDING_MOBILE = 24;
532
+
533
+ useEffect(() => {
534
+ if (!isDatePickerOpen) {
535
+ setPickerPosition(null);
536
+ return;
537
+ }
538
+
539
+ // Calculate position from trigger button (fixed = viewport coords, no scroll offset)
540
+ if (datePickerTriggerRef.current) {
541
+ const rect = datePickerTriggerRef.current.getBoundingClientRect();
542
+ const padding = window.innerWidth < MOBILE_BREAKPOINT ? VIEWPORT_PADDING_MOBILE : VIEWPORT_PADDING;
543
+ let left: number;
544
+ let top = rect.bottom + 4;
545
+
546
+ // On mobile, center the dropdown for better visibility and to avoid going off-screen
547
+ if (window.innerWidth < MOBILE_BREAKPOINT) {
548
+ left = (window.innerWidth - PICKER_MIN_WIDTH) / 2;
549
+ left = Math.max(padding, Math.min(left, window.innerWidth - PICKER_MIN_WIDTH - padding));
550
+ } else {
551
+ left = rect.left;
552
+ // Keep dropdown within viewport horizontally on desktop
553
+ if (left + PICKER_MIN_WIDTH > window.innerWidth - padding) {
554
+ left = window.innerWidth - PICKER_MIN_WIDTH - padding;
555
+ }
556
+ if (left < padding) {
557
+ left = padding;
558
+ }
559
+ }
560
+
561
+ // Keep dropdown within viewport vertically
562
+ if (top + PICKER_MAX_HEIGHT > window.innerHeight - padding) {
563
+ top = Math.max(padding, window.innerHeight - PICKER_MAX_HEIGHT - padding);
564
+ }
565
+ if (top < padding) {
566
+ top = padding;
567
+ }
568
+
569
+ setPickerPosition({ top, left });
570
+ }
571
+
572
+ const handleClickOutside = (event: MouseEvent) => {
573
+ const target = event.target as Node;
574
+ // Check if click is outside both the popup and the trigger button
575
+ const isOutsidePopup = datePickerRef.current && !datePickerRef.current.contains(target);
576
+ const isOutsideTrigger = datePickerTriggerRef.current && !datePickerTriggerRef.current.contains(target);
577
+
578
+ if (isOutsidePopup && isOutsideTrigger) {
579
+ setIsDatePickerOpen(false);
580
+ setPickerPosition(null);
581
+ }
582
+ };
583
+
584
+ // Use a small delay to avoid immediate closure when opening
585
+ const timeoutId = setTimeout(() => {
586
+ document.addEventListener('click', handleClickOutside, true);
587
+ }, 100);
588
+
589
+ return () => {
590
+ clearTimeout(timeoutId);
591
+ document.removeEventListener('click', handleClickOutside, true);
592
+ };
593
+ }, [isDatePickerOpen]);
594
+
595
+ // Derive Date from stored string (for comparisons and addDays) - uses company timezone
596
+ const currentStartDate = useMemo(
597
+ () => dateStrToNoonInTz(currentStartDateStr, timezone),
598
+ [currentStartDateStr, timezone]
599
+ );
600
+
601
+ // Check if we can navigate backwards/forwards
602
+ const canNavigateBack = useMemo(() => {
603
+ if (availableDates.length === 0) return false;
604
+ const firstAvailableDate = availableDates[0];
605
+ return firstAvailableDate < currentStartDate;
606
+ }, [availableDates, currentStartDate]);
607
+
608
+ const canNavigateForward = useMemo(() => {
609
+ if (availableDates.length === 0) return false;
610
+ const lastAvailableDate = availableDates[availableDates.length - 1];
611
+ const currentEndDate = addDays(currentStartDate, WEEKS_TO_SHOW * 7 - 1);
612
+ return lastAvailableDate > currentEndDate;
613
+ }, [availableDates, currentStartDate]);
614
+
615
+ const lastReportedRangeRef = useRef<{ start: Date; end: Date } | null>(null);
616
+ const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
617
+ const hasSyncedChangeFlowWeekRef = useRef(false);
618
+
619
+ const handleDateJump = useCallback((date: Date) => {
620
+ const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
621
+ const weekStartStr = getSundayOfWeek(dateStr, timezone);
622
+ const visibleStart = dateStrToNoonInTz(weekStartStr, timezone);
623
+ const visibleEnd = addDays(visibleStart, WEEKS_TO_SHOW * 7 - 1);
624
+ const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
625
+ const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
626
+
627
+ if (debounceTimeoutRef.current) {
628
+ clearTimeout(debounceTimeoutRef.current);
629
+ debounceTimeoutRef.current = null;
630
+ }
631
+
632
+ lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
633
+ setCurrentStartDateStr(weekStartStr);
634
+ if (onVisibleRangeChange) {
635
+ onVisibleRangeChange(bufferStart, bufferEnd);
636
+ }
637
+
638
+ onDateSelect(dateStr);
639
+ }, [timezone, onVisibleRangeChange, onDateSelect, WEEKS_TO_SHOW]);
640
+
641
+ useEffect(() => {
642
+ if (!syncVisibleWeekToSelectedDate || !selectedDate || hasSyncedChangeFlowWeekRef.current) {
643
+ return;
644
+ }
645
+ hasSyncedChangeFlowWeekRef.current = true;
646
+ const weekStartStr = getSundayOfWeek(selectedDate, timezone);
647
+ const visibleStart = dateStrToNoonInTz(weekStartStr, timezone);
648
+ const visibleEnd = addDays(visibleStart, WEEKS_TO_SHOW * 7 - 1);
649
+ const bufferStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
650
+ const bufferEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
651
+ if (debounceTimeoutRef.current) {
652
+ clearTimeout(debounceTimeoutRef.current);
653
+ debounceTimeoutRef.current = null;
654
+ }
655
+ lastReportedRangeRef.current = { start: bufferStart, end: bufferEnd };
656
+ setCurrentStartDateStr(weekStartStr);
657
+ if (onVisibleRangeChange) {
658
+ onVisibleRangeChange(bufferStart, bufferEnd);
659
+ }
660
+ }, [
661
+ syncVisibleWeekToSelectedDate,
662
+ selectedDate,
663
+ timezone,
664
+ WEEKS_TO_SHOW,
665
+ onVisibleRangeChange,
666
+ ]);
667
+
668
+ // Generate calendar days (2 weeks = 14 days)
669
+ const calendarDays = useMemo(() => {
670
+ const days: Date[] = [];
671
+ for (let i = 0; i < WEEKS_TO_SHOW * 7; i++) {
672
+ days.push(addDays(currentStartDate, i));
673
+ }
674
+ return days;
675
+ }, [currentStartDate]);
676
+
677
+ // Notify parent of visible date range changes (with buffer weeks before/after)
678
+ // When dropdown is open, expand range to include the visible month so we fetch availability for it
679
+ useEffect(() => {
680
+ if (!onVisibleRangeChange || calendarDays.length === 0) return;
681
+
682
+ const visibleStart = calendarDays[0];
683
+ const visibleEnd = calendarDays[calendarDays.length - 1];
684
+ // Add buffer weeks before and after
685
+ let reportStart = subWeeks(visibleStart, VISIBLE_RANGE_BUFFER_WEEKS);
686
+ let reportEnd = addWeeks(visibleEnd, VISIBLE_RANGE_BUFFER_WEEKS);
687
+
688
+ // When dropdown is open, expand range to include the picker month so we fetch availability for future months
689
+ if (isDatePickerOpen) {
690
+ const pickerMonthStart = startOfMonth(pickerMonth);
691
+ const pickerMonthEnd = endOfMonth(pickerMonth);
692
+ reportStart = reportStart < pickerMonthStart ? reportStart : pickerMonthStart;
693
+ reportEnd = reportEnd > pickerMonthEnd ? reportEnd : pickerMonthEnd;
694
+ }
695
+
696
+ // Only report if range actually changed (more than a day difference)
697
+ const currentReported = lastReportedRangeRef.current;
698
+ const rangeChanged = !currentReported ||
699
+ Math.abs(reportStart.getTime() - currentReported.start.getTime()) > 24 * 60 * 60 * 1000 ||
700
+ Math.abs(reportEnd.getTime() - currentReported.end.getTime()) > 24 * 60 * 60 * 1000;
701
+
702
+ if (rangeChanged) {
703
+ if (isDatePickerOpen) {
704
+ // Report immediately when dropdown is open - user may close before debounce fires
705
+ if (debounceTimeoutRef.current) {
706
+ clearTimeout(debounceTimeoutRef.current);
707
+ debounceTimeoutRef.current = null;
708
+ }
709
+ lastReportedRangeRef.current = { start: reportStart, end: reportEnd };
710
+ onVisibleRangeChange(reportStart, reportEnd);
711
+ } else {
712
+ // Debounce when main calendar navigates
713
+ if (debounceTimeoutRef.current) {
714
+ clearTimeout(debounceTimeoutRef.current);
715
+ }
716
+ debounceTimeoutRef.current = setTimeout(() => {
717
+ lastReportedRangeRef.current = { start: reportStart, end: reportEnd };
718
+ onVisibleRangeChange(reportStart, reportEnd);
719
+ }, 300);
720
+ }
721
+ }
722
+
723
+ return () => {
724
+ if (debounceTimeoutRef.current) {
725
+ clearTimeout(debounceTimeoutRef.current);
726
+ }
727
+ };
728
+ }, [calendarDays, onVisibleRangeChange, isDatePickerOpen, pickerMonth]);
729
+
730
+ // Calculate date availability info
731
+ // Use plain object instead of Map so React can detect changes properly
732
+ const dateAvailabilityMap = useMemo(() => {
733
+ const map: Record<string, DateAvailability> = {};
734
+
735
+ Object.entries(availabilitiesByDate).forEach(([dateStr, availabilities]) => {
736
+ if (availabilities && availabilities.length > 0) {
737
+ // Calculate min price from ADULT prices only (for "from" price display)
738
+ // Priority: pricesByCategory.retailPrices (in cents) > rates.price (in dollars)
739
+ const adultPrices = availabilities.flatMap(avail => {
740
+ // First try pricesByCategory.retailPrices (preferred format, in cents)
741
+ if (avail.pricesByCategory?.retailPrices) {
742
+ const adultPrice = avail.pricesByCategory.retailPrices.find(p => p.category === 'ADULT');
743
+ return adultPrice ? [adultPrice.price / 100] : []; // Convert cents to dollars
744
+ }
745
+ // Fallback to rates.price (already in dollars)
746
+ if (avail.rates) {
747
+ const adultRate = avail.rates.find(rate => rate.category === 'ADULT');
748
+ return adultRate && adultRate.price !== undefined && adultRate.price !== null
749
+ ? [adultRate.price]
750
+ : [];
751
+ }
752
+ return [];
753
+ });
754
+
755
+ const minPrice = adultPrices.length > 0 ? Math.min(...adultPrices) : undefined;
756
+
757
+ // Check if sold out (all availabilities have 0 vacancies)
758
+ const isSoldOut = availabilities.every(avail => avail.vacancies === 0);
759
+
760
+ // Calculate total vacancies across all availabilities for this date
761
+ const totalVacancies = availabilities.reduce((sum, avail) => sum + avail.vacancies, 0);
762
+
763
+ // Extract start times from availabilities and track which are sold out
764
+ const timeAvailabilityMap = new Map<string, { isSoldOut: boolean; vacancies: number }>();
765
+ const timeCapacityMap: Record<string, { booked: number; total: number }> = {};
766
+
767
+ availabilities.forEach(avail => {
768
+ let timeStr: string | null = null;
769
+ try {
770
+ // Parse ISO datetime and extract time portion
771
+ const dateTime = parseAvailabilityDateTime(avail.dateTime);
772
+ timeStr = formatInTimeZone(dateTime, timezone, 'HH:mm');
773
+ } catch {
774
+ const timeMatch = avail.dateTime.match(/T(\d{2}:\d{2})/);
775
+ if (timeMatch) timeStr = timeMatch[1];
776
+ }
777
+ if (timeStr) {
778
+ const existing = timeAvailabilityMap.get(timeStr);
779
+ if (existing) {
780
+ timeAvailabilityMap.set(timeStr, {
781
+ isSoldOut: existing.isSoldOut && avail.vacancies === 0,
782
+ vacancies: existing.vacancies + avail.vacancies
783
+ });
784
+ } else {
785
+ timeAvailabilityMap.set(timeStr, {
786
+ isSoldOut: avail.vacancies === 0,
787
+ vacancies: avail.vacancies
788
+ });
789
+ }
790
+ // Per-time capacity for admin: first availability for this time wins
791
+ const totalCapacity = avail.totalCapacity ?? 0;
792
+ if (totalCapacity > 0 && !timeCapacityMap[timeStr]) {
793
+ const booked = avail.bookedCapacity ?? (totalCapacity - avail.vacancies);
794
+ timeCapacityMap[timeStr] = { booked, total: totalCapacity };
795
+ }
796
+ }
797
+ });
798
+
799
+ const timeCapacityMapWithVacancies: Record<string, { booked: number; total: number; vacancies: number }> = {};
800
+ for (const [ts, cap] of Object.entries(timeCapacityMap)) {
801
+ const agg = timeAvailabilityMap.get(ts);
802
+ timeCapacityMapWithVacancies[ts] = {
803
+ booked: cap.booked,
804
+ total: cap.total,
805
+ vacancies: agg?.vacancies ?? Math.max(0, cap.total - cap.booked),
806
+ };
807
+ }
808
+
809
+ const startTimes = Array.from(timeAvailabilityMap.keys()).sort();
810
+ const soldOutTimes = new Set(
811
+ Array.from(timeAvailabilityMap.entries())
812
+ .filter(([, info]) => info.isSoldOut)
813
+ .map(([time]) => time)
814
+ );
815
+
816
+ // Calculate total discount percentage from all negative adjustments
817
+ const dynamicDiscountPercent = calculateTotalDiscountPercent(availabilities, currency) ?? 0;
818
+ const totalDiscountPercent =
819
+ dynamicDiscountPercent + Math.max(0, extraDiscountPercent);
820
+
821
+ map[dateStr] = {
822
+ date: dateStr,
823
+ availabilityCount: availabilities.length,
824
+ isSoldOut,
825
+ minPrice,
826
+ totalVacancies,
827
+ startTimes: startTimes.length > 0 ? startTimes : undefined,
828
+ soldOutTimes: soldOutTimes.size > 0 ? soldOutTimes : undefined,
829
+ totalDiscountPercent: totalDiscountPercent > 0 ? totalDiscountPercent : undefined,
830
+ timeCapacityMap:
831
+ Object.keys(timeCapacityMapWithVacancies).length > 0 ? timeCapacityMapWithVacancies : undefined,
832
+ };
833
+ }
834
+ });
835
+
836
+ if (capDiscountToSelectedDate && selectedDate) {
837
+ const selectedDiscount = map[selectedDate]?.totalDiscountPercent;
838
+ if (selectedDiscount != null && selectedDiscount > 0) {
839
+ const capped: Record<string, DateAvailability> = {};
840
+ for (const [dateStr, availability] of Object.entries(map)) {
841
+ const raw = availability.totalDiscountPercent;
842
+ const adjusted = raw != null ? Math.min(raw, selectedDiscount) : undefined;
843
+ capped[dateStr] = {
844
+ ...availability,
845
+ totalDiscountPercent: adjusted,
846
+ };
847
+ }
848
+ return capped;
849
+ }
850
+ }
851
+
852
+ return map;
853
+ }, [availabilitiesByDate, timezone, currency, extraDiscountPercent, capDiscountToSelectedDate, selectedDate]);
854
+
855
+ // Get display range for header
856
+ const displayRange = useMemo(() => {
857
+ const firstDay = calendarDays[0];
858
+ const lastDay = calendarDays[calendarDays.length - 1];
859
+ return {
860
+ start: formatInTimeZone(firstDay, timezone, 'MMM d', { locale: dateFnsLocale }),
861
+ end: formatInTimeZone(lastDay, timezone, 'MMM d, yyyy', { locale: dateFnsLocale }),
862
+ };
863
+ }, [calendarDays, timezone, dateFnsLocale]);
864
+
865
+ // Navigation handlers - add/subtract weeks in company timezone
866
+ const handlePrevious = () => {
867
+ const prevSunday = addDays(currentStartDate, -WEEKS_TO_SHOW * 7);
868
+ setCurrentStartDateStr(formatInTimeZone(prevSunday, timezone, 'yyyy-MM-dd'));
869
+ };
870
+
871
+ const handleNext = () => {
872
+ const nextSunday = addDays(currentStartDate, WEEKS_TO_SHOW * 7);
873
+ setCurrentStartDateStr(formatInTimeZone(nextSunday, timezone, 'yyyy-MM-dd'));
874
+ };
875
+
876
+ const handleDateClick = (date: Date) => {
877
+ const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
878
+ const availability = dateAvailabilityMap[dateStr] || null;
879
+
880
+ // Allow selection when there are availabilities; when showCapacity (admin), allow sold-out for overbooking
881
+ if (availability && (!availability.isSoldOut || showCapacity)) {
882
+ onDateSelect(dateStr);
883
+ }
884
+ };
885
+
886
+ return (
887
+ <div className={styles.calendar}>
888
+ {/* Calendar Header with Navigation */}
889
+ <div className={styles.calendarHeader}>
890
+ <button
891
+ onClick={handlePrevious}
892
+ disabled={!canNavigateBack}
893
+ className={styles.calendarNav}
894
+ aria-label={t('calendar.previousWeeks')}
895
+ >
896
+ <svg
897
+ className={styles.calendarNavIcon}
898
+ fill="none"
899
+ stroke="currentColor"
900
+ viewBox="0 0 24 24"
901
+ >
902
+ <path
903
+ strokeLinecap="round"
904
+ strokeLinejoin="round"
905
+ strokeWidth={2}
906
+ d="M15 19l-7-7 7-7"
907
+ />
908
+ </svg>
909
+ </button>
910
+ <div className="relative">
911
+ <button
912
+ ref={datePickerTriggerRef}
913
+ type="button"
914
+ onClick={(e) => {
915
+ e.stopPropagation();
916
+ setIsDatePickerOpen((prev) => !prev);
917
+ }}
918
+ className={styles.calendarRangeTrigger}
919
+ >
920
+ <span>{displayRange.start} - {displayRange.end}</span>
921
+ <svg
922
+ className={cn('w-3 h-3 transition-transform', isDatePickerOpen && 'rotate-180')}
923
+ fill="none"
924
+ stroke="currentColor"
925
+ viewBox="0 0 24 24"
926
+ >
927
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
928
+ </svg>
929
+ </button>
930
+ {isDatePickerOpen && pickerPosition && typeof document !== 'undefined' && createPortal(
931
+ <div
932
+ ref={datePickerRef}
933
+ className={styles.calendarDropdown}
934
+ style={{
935
+ top: `${pickerPosition.top}px`,
936
+ left: `${pickerPosition.left}px`,
937
+ }}
938
+ onClick={(e) => e.stopPropagation()}
939
+ >
940
+ {/* Mini Calendar Header - always visible so users can change months while loading */}
941
+ <div className={styles.calendarDropdownHeader}>
942
+ <button
943
+ type="button"
944
+ onClick={(e) => {
945
+ e.preventDefault();
946
+ e.stopPropagation();
947
+ if (!canNavigatePickerMonthBack) return;
948
+ const previousMonth = startOfMonth(subMonths(pickerMonth, 1));
949
+ // Clamp to June-October 2026 range
950
+ if (previousMonth.getTime() >= MINI_CALENDAR_START_MONTH.getTime()) {
951
+ setPickerMonth(previousMonth);
952
+ }
953
+ }}
954
+ disabled={!canNavigatePickerMonthBack}
955
+ className={styles.calendarDropdownNav}
956
+ >
957
+ <svg className={styles.calendarDropdownNavIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
958
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
959
+ </svg>
960
+ </button>
961
+ <div className={styles.calendarDropdownMonth}>
962
+ {formatInTimeZone(pickerMonth, timezone, 'MMMM yyyy', { locale: dateFnsLocale })}
963
+ </div>
964
+ <button
965
+ type="button"
966
+ onClick={(e) => {
967
+ e.preventDefault();
968
+ e.stopPropagation();
969
+ if (!canNavigatePickerMonthForward) return;
970
+ const nextMonth = startOfMonth(addMonths(pickerMonth, 1));
971
+ // Clamp to June-October 2026 range
972
+ if (nextMonth.getTime() <= MINI_CALENDAR_END_MONTH.getTime()) {
973
+ setPickerMonth(nextMonth);
974
+ }
975
+ }}
976
+ disabled={!canNavigatePickerMonthForward}
977
+ className={styles.calendarDropdownNav}
978
+ >
979
+ <svg className={styles.calendarDropdownNavIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
980
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
981
+ </svg>
982
+ </button>
983
+ </div>
984
+
985
+ {/* Grid area - overlay only covers this, not the header */}
986
+ <div className={styles.calendarDropdownGridWrapper}>
987
+ {showSpinnerInDropdown && (
988
+ <div className={styles.calendarDropdownLoading} aria-hidden>
989
+ <div className="booking-calendar-dropdown-spinner" />
990
+ </div>
991
+ )}
992
+ {/* Mini Calendar Days of Week */}
993
+ <div className={styles.calendarDropdownDaysOfWeek}>
994
+ {DAYS_OF_WEEK_KEYS.map((dayKey) => (
995
+ <div key={dayKey} className={styles.calendarDropdownDow}>
996
+ {t(`calendar.days.${dayKey}`)[0]}
997
+ </div>
998
+ ))}
999
+ </div>
1000
+
1001
+ {/* Mini Calendar Grid */}
1002
+ <div className={styles.calendarDropdownDays}>
1003
+ {miniCalendarData.days.map((day) => {
1004
+ const dateStr = formatInTimeZone(day, timezone, 'yyyy-MM-dd');
1005
+ const dayNumber = formatInTimeZone(day, timezone, 'd');
1006
+ const availability = dateAvailabilityMap[dateStr] || null;
1007
+ const isSelected = selectedDate === dateStr;
1008
+ const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
1009
+ // Check if day is within allowed range (June 1 - October 12, 2026) in company timezone
1010
+ const isInAllowedRange = day >= miniCalendarBoundsInTz.start && day <= miniCalendarBoundsInTz.end;
1011
+
1012
+ // Only treat as available if we have actual availability data for this date
1013
+ const hasAvailabilityData = availability !== null;
1014
+ const isSoldOut = availability?.isSoldOut === true;
1015
+ // When showCapacity (admin), sold-out days are still selectable for overbooking
1016
+ const isAvailable = hasAvailabilityData && isInAllowedRange && (!isSoldOut || showCapacity);
1017
+ const isToday = isSameDay(day, new Date());
1018
+
1019
+ const dayClass = cn(
1020
+ styles.calendarDropdownDay,
1021
+ !isCurrentMonth && styles.calendarDropdownDayMuted,
1022
+ isSoldOut && !showCapacity && styles.calendarDropdownDaySoldOut,
1023
+ isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
1024
+ !isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
1025
+ !hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
1026
+ isAvailable && isSelected && styles.calendarDropdownDaySelected,
1027
+ isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
1028
+ isToday && isAvailable && !isSelected && styles.calendarDropdownDayToday
1029
+ );
1030
+
1031
+ return (
1032
+ <button
1033
+ key={dateStr}
1034
+ onClick={() => {
1035
+ if (isAvailable) {
1036
+ handleDateJump(day);
1037
+ setIsDatePickerOpen(false);
1038
+ }
1039
+ }}
1040
+ disabled={!isAvailable}
1041
+ className={dayClass}
1042
+ >
1043
+ {dayNumber}
1044
+ </button>
1045
+ );
1046
+ })}
1047
+ </div>
1048
+ </div>
1049
+ </div>,
1050
+ document.body
1051
+ )}
1052
+ </div>
1053
+ <button
1054
+ onClick={handleNext}
1055
+ disabled={!canNavigateForward}
1056
+ className={styles.calendarNav}
1057
+ aria-label={t('calendar.nextWeeks')}
1058
+ >
1059
+ <svg
1060
+ className={styles.calendarNavIcon}
1061
+ fill="none"
1062
+ stroke="currentColor"
1063
+ viewBox="0 0 24 24"
1064
+ >
1065
+ <path
1066
+ strokeLinecap="round"
1067
+ strokeLinejoin="round"
1068
+ strokeWidth={2}
1069
+ d="M9 5l7 7-7 7"
1070
+ />
1071
+ </svg>
1072
+ </button>
1073
+ </div>
1074
+
1075
+ {/* Calendar Grid */}
1076
+ <div className={styles.calendarGrid}>
1077
+ <div className={styles.calendarGridInner}>
1078
+ {/* Day Headers */}
1079
+ <div className={cn(styles.calendarHeaderRow, 'calendar-header-grid')}>
1080
+ {DAYS_OF_WEEK_KEYS.map((dayKey) => (
1081
+ <div
1082
+ key={dayKey}
1083
+ className={styles.calendarHeaderCell}
1084
+ >
1085
+ {t(`calendar.days.${dayKey}`)}
1086
+ </div>
1087
+ ))}
1088
+ </div>
1089
+
1090
+ {/* Calendar Days Grid - row height = tallest cell in row; all cells stretch to that height */}
1091
+ <div className={cn(styles.calendarDaysGrid, 'calendar-days-grid')}>
1092
+ {calendarDays.map((date) => {
1093
+ const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
1094
+ const availability = dateAvailabilityMap[dateStr] || null;
1095
+ const isSelected = selectedDate === dateStr;
1096
+ const isToday = isSameDay(date, new Date());
1097
+
1098
+ // Include availability data in key to force re-render when availability changes
1099
+ const availabilityKey = availability
1100
+ ? `${availability.availabilityCount}-${availability.isSoldOut}-${availability.startTimes?.join(',') || ''}`
1101
+ : 'none';
1102
+ return (
1103
+ <DateCell
1104
+ key={`${dateStr}-${availabilityKey}`}
1105
+ date={date}
1106
+ dateStr={dateStr}
1107
+ availability={availability}
1108
+ isSelected={isSelected}
1109
+ isToday={isToday}
1110
+ showCapacity={showCapacity}
1111
+ timezone={timezone}
1112
+ displayMode={displayMode}
1113
+ onClick={() => handleDateClick(date)}
1114
+ isMobile={isMobile}
1115
+ />
1116
+ );
1117
+ })}
1118
+ </div>
1119
+ </div>
1120
+ </div>
1121
+ </div>
1122
+ );
1123
+ }