@ticketboothapp/booking 1.2.60 → 1.2.62

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.
@@ -0,0 +1,3256 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
4
+ import { parseISO, addWeeks, format, isBefore, isAfter, startOfDay, endOfDay } from 'date-fns';
5
+ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
6
+ import {
7
+ getAvailabilities,
8
+ createReservation,
9
+ cancelReservation,
10
+ cancelReservationBestEffort,
11
+ createPaymentIntent,
12
+ confirmFreeBooking,
13
+ confirmBookingWithoutPayment,
14
+ confirmPartnerBookingWithoutPayment,
15
+ getAddOns,
16
+ validatePromoCode,
17
+ getPromoDiscount,
18
+ type Product,
19
+ type Availability,
20
+ type ReturnOption,
21
+ type AddOn,
22
+ isInsufficientCapacityReserveError,
23
+ describeStandardTourCapacityConflictMessage,
24
+ reportReserveCapacityConflictClientContext,
25
+ } from '../../lib/booking-api';
26
+ import {
27
+ EARLIEST_AVAILABILITY_DATE,
28
+ LATEST_AVAILABILITY_DATE,
29
+ INITIAL_FETCH_WEEKS,
30
+ } from '../../lib/booking-constants';
31
+ import { Calendar } from './Calendar';
32
+ import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
33
+ import { ItineraryBox } from './ItineraryBox';
34
+ import { ItineraryPlaceholder } from './ItineraryPlaceholder';
35
+ import { PickupTimeSelector } from './PickupTimeSelector';
36
+ import { ReturnTimeSelector } from './ReturnTimeSelector';
37
+ import { CancellationPolicySelector } from './CancellationPolicySelector';
38
+ import { TicketSelector } from './TicketSelector';
39
+ import { AddOnsSection } from './AddOnsSection';
40
+ import { CheckoutForm } from './CheckoutForm';
41
+ import { PromoCodeInput } from './PromoCodeInput';
42
+ import { useTranslations, useLocale } from '../../lib/booking/i18n';
43
+ import { type Currency } from './CurrencySwitcher';
44
+ import { formatBookingRefForDisplay } from '../../lib/booking-ref';
45
+ import { formatCurrencyAmount } from '../../lib/currency';
46
+ import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
47
+ import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
48
+ import { ItineraryStepType as StepType } from '../../lib/booking-api';
49
+ import {
50
+ getDisplayPriceFromBaseInDisplayCurrency,
51
+ computePriceBreakdown,
52
+ computeOrderSummary,
53
+ type PriceBreakdown as PriceBreakdownData,
54
+ type OrderSummary,
55
+ } from '../../lib/booking/pricing';
56
+ import { useCompanyTimezone } from '../../contexts/CompanyContext';
57
+ import { useBookingApp } from '../../contexts/BookingAppContext';
58
+ import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
59
+ import { type PriceSummaryLine } from './PriceSummary';
60
+ import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
61
+ import { BookingFlowCollage } from './BookingFlowCollage';
62
+ import { TourDescription } from './TourDescription';
63
+ import {
64
+ buildBookingSourceContext,
65
+ inferClientBookingSourceFromProductIds,
66
+ type BookingSourceMetadata,
67
+ } from '../../lib/booking/source-metadata';
68
+ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
69
+ import { useBookingHost } from '../../runtime';
70
+ import type { BookingFlowUiOptions } from './booking-flow-ui';
71
+ import type { NewBookingFlowProps } from './booking-flow-types';
72
+ import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
73
+
74
+ function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
75
+ const labels: Record<string, string> = {
76
+ ADULT: 'adult',
77
+ CHILD: 'child',
78
+ INFANT: 'infant',
79
+ SENIOR: 'senior',
80
+ STUDENT: 'student',
81
+ };
82
+ const parts = lines
83
+ .filter((l) => l.qty > 0)
84
+ .map((l) => {
85
+ const label = labels[l.category] || l.category.toLowerCase();
86
+ return `${l.qty} ${label}${l.qty !== 1 ? 's' : ''}`;
87
+ });
88
+ return parts.length > 0 ? parts.join(', ') : '—';
89
+ }
90
+
91
+ function normalizeTicketCategoryFromReceiptLabel(raw: string): string | null {
92
+ const normalized = raw.trim().toUpperCase();
93
+ if (!normalized) return null;
94
+ if (['ADULT', 'CHILD', 'INFANT', 'SENIOR', 'STUDENT'].includes(normalized)) return normalized;
95
+ const compact = normalized.replace(/[^A-Z]/g, ' ').replace(/\s+/g, ' ').trim();
96
+ if (/\bADULTS?\b/.test(compact)) return 'ADULT';
97
+ if (/\bCHILD(REN)?\b/.test(compact)) return 'CHILD';
98
+ if (/\bINFANTS?\b/.test(compact)) return 'INFANT';
99
+ if (/\bSENIORS?\b/.test(compact)) return 'SENIOR';
100
+ if (/\bSTUDENTS?\b/.test(compact)) return 'STUDENT';
101
+ return null;
102
+ }
103
+
104
+ function extractTrailingQty(label: string): { baseLabel: string; qty: number } {
105
+ const trimmed = label.trim();
106
+ const m = trimmed.match(/^(.*?)(?:\s*[×x]\s*(\d+))$/);
107
+ if (!m) return { baseLabel: trimmed, qty: 1 };
108
+ const baseLabel = (m[1] || '').trim();
109
+ const qty = Math.max(1, Number(m[2]) || 1);
110
+ return { baseLabel, qty };
111
+ }
112
+
113
+ function deriveAddOnSelectionsFromReceiptLines(
114
+ addOns: AddOn[],
115
+ lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
116
+ ): Array<{ addOnId: string; variantId?: string; quantity?: number }> {
117
+ const selections: Array<{ addOnId: string; variantId?: string; quantity?: number }> = [];
118
+ for (const line of lines) {
119
+ const type = (line.type || '').trim().toUpperCase();
120
+ if (type !== 'FEE') continue;
121
+ const amount = Number(line.amount ?? 0);
122
+ if (!Number.isFinite(amount) || amount <= 0) continue;
123
+ const rawLabel = (line.label || '').trim();
124
+ if (!rawLabel) continue;
125
+
126
+ const { baseLabel, qty } = extractTrailingQty(rawLabel);
127
+ let matched = false;
128
+
129
+ for (const addOn of addOns) {
130
+ if (matched) break;
131
+ const addOnName = addOn.name?.trim();
132
+ if (!addOnName) continue;
133
+
134
+ if (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') {
135
+ for (const variant of addOn.variants ?? []) {
136
+ const variantLabel = variant.label?.trim();
137
+ if (!variantLabel) continue;
138
+ if (baseLabel === `${addOnName} (${variantLabel})`) {
139
+ selections.push({ addOnId: addOn.addOnId, variantId: variant.id, quantity: qty });
140
+ matched = true;
141
+ break;
142
+ }
143
+ }
144
+ } else if (baseLabel === addOnName) {
145
+ selections.push({ addOnId: addOn.addOnId, quantity: qty });
146
+ matched = true;
147
+ }
148
+ }
149
+ }
150
+ return selections;
151
+ }
152
+
153
+ function findMergedAvailabilityForSelection(
154
+ merged: Availability[],
155
+ selected: Availability | null
156
+ ): Availability | undefined {
157
+ if (!selected) return undefined;
158
+ const optId = selected.productOptionId ?? undefined;
159
+ const dt = selected.dateTime;
160
+ const availId = selected.availabilityId?.trim();
161
+ if (availId && optId) {
162
+ const exact = merged.find(
163
+ (a) =>
164
+ a.availabilityId === availId &&
165
+ (a.productOptionId === optId || a.productOptionId === undefined)
166
+ );
167
+ if (exact) return exact;
168
+ }
169
+ return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
170
+ }
171
+
172
+ /** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
173
+ function buildPricingFromAvailability(
174
+ selectedAvailability: Availability | null,
175
+ activeOptions: Product['options'],
176
+ precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
177
+ currency: Currency,
178
+ pricingConfig: PricingConfig | null,
179
+ hasFees: boolean,
180
+ isSimplifiedPricingView: boolean,
181
+ ) {
182
+ if (!selectedAvailability || !pricingConfig) return [];
183
+ const optionId = selectedAvailability.productOptionId;
184
+ const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
185
+ const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
186
+ const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
187
+ getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
188
+ const getBaseInDisplayCurrency = (category: string) => {
189
+ const fromPrecomputed = precomputed?.[category]?.[currency];
190
+ if (fromPrecomputed != null) return fromPrecomputed;
191
+ return 0;
192
+ };
193
+ const buildRate = (
194
+ category: string,
195
+ backendPriceCAD: number,
196
+ backendInDisplayCurrency: number,
197
+ baseInDisplayCurrency: number,
198
+ appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
199
+ ) => {
200
+ const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
201
+ const isPublicMode = isSimplifiedPricingView;
202
+ const breakdown = computePriceBreakdown(
203
+ pricingConfig,
204
+ currency,
205
+ backendPriceCAD,
206
+ basePriceCAD,
207
+ hasFees,
208
+ appliedAdjustments,
209
+ undefined,
210
+ baseInDisplayCurrency,
211
+ isPublicMode,
212
+ );
213
+ const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
214
+ return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
215
+ };
216
+ return (
217
+ selectedAvailability.rates?.map((rate) => {
218
+ const backendPriceCAD = rate.price ?? 0;
219
+ const backendInDisplayCurrency =
220
+ rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
221
+ const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
222
+ const built = buildRate(
223
+ rate.category,
224
+ backendPriceCAD,
225
+ backendInDisplayCurrency,
226
+ baseInDisplayCurrency,
227
+ rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
228
+ );
229
+ return {
230
+ category: rate.category,
231
+ rateId: rate.rateId || rate.category,
232
+ available: rate.available,
233
+ price: built.price,
234
+ priceCAD: built.priceCAD,
235
+ baseInDisplayCurrency: built.baseInDisplayCurrency,
236
+ appliedAdjustments: built.appliedAdjustments,
237
+ };
238
+ }) ||
239
+ selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
240
+ const priceCADFromApi = p.price / 100;
241
+ const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
242
+ const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
243
+ const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
244
+ return {
245
+ category: p.category,
246
+ rateId: p.category,
247
+ available: selectedAvailability.vacancies,
248
+ price: built.price,
249
+ priceCAD: built.priceCAD,
250
+ baseInDisplayCurrency: built.baseInDisplayCurrency,
251
+ appliedAdjustments: built.appliedAdjustments,
252
+ };
253
+ }) ||
254
+ []
255
+ );
256
+ }
257
+
258
+ function findMergedReturnVacancies(
259
+ outbound: Availability | undefined,
260
+ selectedReturn: ReturnOption | null | undefined
261
+ ): number | null {
262
+ if (!selectedReturn?.returnAvailabilityId || !outbound?.returnOptions?.length) return null;
263
+ const ro = outbound.returnOptions.find(
264
+ (r) => r.returnAvailabilityId === selectedReturn.returnAvailabilityId
265
+ );
266
+ return ro ? ro.vacancies : null;
267
+ }
268
+
269
+ function normalizeAddOnSelections(
270
+ selections: Array<{ addOnId: string; variantId?: string; quantity?: number }>
271
+ ): Array<{ addOnId: string; variantId?: string; quantity?: number }> {
272
+ const qtyByKey = new Map<string, number>();
273
+ for (const sel of selections) {
274
+ const addOnId = sel.addOnId?.trim();
275
+ if (!addOnId) continue;
276
+ const variantId = sel.variantId?.trim() || '';
277
+ const key = `${addOnId}::${variantId}`;
278
+ qtyByKey.set(key, (qtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
279
+ }
280
+ return Array.from(qtyByKey.entries()).map(([key, qty]) => {
281
+ const [addOnId, variantIdRaw] = key.split('::');
282
+ const variantId = variantIdRaw?.trim() || undefined;
283
+ return { addOnId, variantId, quantity: qty };
284
+ });
285
+ }
286
+
287
+ function parseAvailabilityDateTime(value: string): Date {
288
+ // If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
289
+ const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
290
+ return parseISO(hasExplicitOffset ? value : `${value}Z`);
291
+ }
292
+
293
+ function deriveDefaultQuantitiesFromAvailabilities(
294
+ availabilities: Availability[]
295
+ ): Record<string, number> | null {
296
+ const firstWithRates = availabilities.find((avail) => avail.rates && avail.rates.length > 0);
297
+ if (!firstWithRates?.rates?.length) return null;
298
+
299
+ const initialQuantities: Record<string, number> = {};
300
+ firstWithRates.rates.forEach((rate) => {
301
+ initialQuantities[rate.category] = rate.category === 'ADULT' ? 1 : 0;
302
+ });
303
+
304
+ return Object.keys(initialQuantities).length > 0 ? initialQuantities : null;
305
+ }
306
+
307
+ export function NewBookingFlow({
308
+ product,
309
+ productId,
310
+ onBack,
311
+ currency,
312
+ contentRef,
313
+ onSuccess,
314
+ isPartialLaunch = false,
315
+ useWindowScroll = false,
316
+ autoAppliedPromoCode,
317
+ calendarDiscountPercent,
318
+ highlightedPickupLocationIds,
319
+ onPricePreviewChange,
320
+ initialValues,
321
+ hideItineraryBox = false,
322
+ flowUi,
323
+ bookingSourceAttribution,
324
+ partnerPortalBooking = false,
325
+ availabilityPricingProfileId,
326
+ availabilityCancellationPolicyProfileId,
327
+ }: NewBookingFlowProps) {
328
+ const { env, strings: defaultStrings, analytics, catalog } = useBookingHost();
329
+ const { t } = useTranslations();
330
+ const { locale } = useLocale();
331
+ const companyTimezone = useCompanyTimezone(); // Get timezone from context
332
+ const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
333
+ const cancellationPolicyProfileIdForAvailabilities =
334
+ (availabilityCancellationPolicyProfileId ?? '').trim() || null;
335
+ const {
336
+ permissions,
337
+ isSimplifiedPricingView,
338
+ onShowManage,
339
+ getSuccessUrl,
340
+ suppressCalendarDateScroll,
341
+ mode: bookingAppMode,
342
+ } = useBookingApp();
343
+ const availabilitiesCache = useAvailabilitiesCache();
344
+ const isAdmin = permissions.viewerRole === 'admin';
345
+ const [availabilities, setAvailabilities] = useState<Availability[]>([]);
346
+ const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
347
+ const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
348
+ const [quantities, setQuantities] = useState<Record<string, number>>({});
349
+ const [email, setEmail] = useState('');
350
+ const [firstName, setFirstName] = useState('');
351
+ const [lastName, setLastName] = useState('');
352
+ const [promoCodeInput, setPromoCodeInput] = useState('');
353
+ const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
354
+ const [promoCodeError, setPromoCodeError] = useState('');
355
+ /** When set by a promo, this policy is forced and shown as a non-selectable row. */
356
+ const [forcedCancellationPolicy, setForcedCancellationPolicy] = useState<{
357
+ id: string;
358
+ label: string;
359
+ refundTiers?: Array<{ hoursBefore: number; refundPercent: number }>;
360
+ changeWindowHoursBefore?: number | null;
361
+ } | null>(null);
362
+ const cancellationPolicyRef = useRef<HTMLDivElement>(null);
363
+ const [promoCodeValidating, setPromoCodeValidating] = useState(false);
364
+ const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
365
+ const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
366
+ // Cancellation: change flow seeds from the booking so totals match the quote (server uses booking.cancellationPolicyId).
367
+ // Standard flow defaults to cheapest policy when pricing config loads.
368
+ const [cancellationPolicyId, setCancellationPolicyId] = useState<string | null>(() => null);
369
+ /** Add-on selections (lunch, animals, etc.) - filtered by selected product option */
370
+ const [addOnSelections, setAddOnSelections] = useState<Array<{ addOnId: string; variantId?: string; quantity?: number }>>(() =>
371
+ normalizeAddOnSelections(initialValues?.addOnSelections ?? [])
372
+ );
373
+ /** Fetched add-ons for the selected product option */
374
+ const [addOns, setAddOns] = useState<AddOn[]>([]);
375
+
376
+ // Auto-apply promo code when parent page passes one (e.g. partner pages).
377
+ // Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
378
+ useEffect(() => {
379
+ if (!autoAppliedPromoCode) return;
380
+ const normalizedPromo = autoAppliedPromoCode.trim().toUpperCase();
381
+ if (!normalizedPromo) return;
382
+ setPromoCodeInput((current) => current || normalizedPromo);
383
+ }, [autoAppliedPromoCode]);
384
+ const [loading, setLoading] = useState(false);
385
+ const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
386
+ /** True when fetching additional availability (e.g. new month in dropdown) - shows spinner in date picker */
387
+ const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
388
+ const [error, setError] = useState('');
389
+ const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
390
+ /** Precomputed prices from ticketbooth-product-prices per option (optionId -> category -> currency -> price). Used for display; rates[].price is for GYG only. */
391
+ const [precomputedPricesByOption, setPrecomputedPricesByOption] = useState<Record<string, PrecomputedPricesByCategory> | null>(null);
392
+ const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
393
+ const fetchingRef = useRef(false); // Prevent concurrent fetches
394
+ const hasLoadedAvailabilitiesRef = useRef(false); // First successful availability paint completed
395
+ const inFlightRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range currently being fetched
396
+ const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
397
+ const pendingRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range to fetch when current fetch completes (user navigated during fetch)
398
+ const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
399
+ const [selectedDate, setSelectedDate] = useState<string>('');
400
+ const [isItinerarySticky, setIsItinerarySticky] = useState(false);
401
+ const isItineraryStickyRef = useRef(false);
402
+ const [isMobile, setIsMobile] = useState(false);
403
+ const [showTooltip, setShowTooltip] = useState(false);
404
+ const itineraryRef = useRef<HTMLDivElement>(null);
405
+ const [showCheckoutModal, setShowCheckoutModal] = useState(false);
406
+ /** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
407
+ const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
408
+ /** True while Stripe is confirming payment/redirecting; skip unload cancellation during this window. */
409
+ const paymentSubmitInFlightRef = useRef(false);
410
+ const [termsAccepted, setTermsAccepted] = useState(false);
411
+ const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
412
+ const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
413
+ const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
414
+ const [checkoutModalData, setCheckoutModalData] = useState<{
415
+ reservationReference: string;
416
+ reservationExpiration?: string;
417
+ customerLastName?: string;
418
+ bookingDate?: string;
419
+ successUrlOverride?: string;
420
+ ticketLines: CheckoutModalLineItem[];
421
+ feeLineItems: OrderSummary['feeLineItems'];
422
+ returnPriceAdjustment: number;
423
+ cancellationPolicyFee: number;
424
+ cancellationPolicyLabel?: string;
425
+ subtotal: number;
426
+ tax: number;
427
+ total: number;
428
+ promoDiscountAmount?: number;
429
+ discountLabel?: string | null;
430
+ totalQuantity: number;
431
+ isTaxIncludedInPrice: boolean;
432
+ taxRate: number;
433
+ changeTotals?: {
434
+ previousTotal: number;
435
+ newTotal: number;
436
+ differenceTotal: number;
437
+ };
438
+ } | null>(null);
439
+ /** Admin only: skip sending confirmation at creation (provider dashboard). */
440
+ const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
441
+ /** Admin only: disable all auto communications for this booking (provider dashboard). */
442
+ const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
443
+ /** Admin only: show choice to pay now or confirm without payment (full balance owed). */
444
+ const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
445
+ const hasAppliedInitialValuesRef = useRef(false);
446
+ const hasAppliedInitialQuantitiesRef = useRef(false);
447
+ const hasHydratedAddOnsFromReceiptRef = useRef(false);
448
+ const hasAutoSelectedPartnerDateRef = useRef(false);
449
+ const hasAutoSelectedPartnerPickupRef = useRef(false);
450
+ const handleDateSelectRef = useRef<(date: string) => void>(() => {});
451
+ useEffect(() => {
452
+ setPartnerAttributionConfirmed(false);
453
+ }, [flowUi?.partnerAttributionSummary, flowUi?.partnerAttributionConfirmLabel]);
454
+ const [adminChoiceData, setAdminChoiceData] = useState<{
455
+ reservationReference: string;
456
+ reservationExpiration?: string;
457
+ checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
458
+ totalAmount: number;
459
+ datePart: string;
460
+ timePart: string;
461
+ availabilityProductOptionId: string;
462
+ itineraryDisplay?: ItineraryDisplayStep[] | null;
463
+ clientSecret: string;
464
+ ticketLinesForModal: CheckoutModalLineItem[];
465
+ feeLineItems: OrderSummary['feeLineItems'];
466
+ returnPriceAdjustment: number;
467
+ cancellationPolicyFee: number;
468
+ cancellationPolicyLabel?: string;
469
+ subtotal: number;
470
+ tax: number;
471
+ totalQuantity: number;
472
+ isTaxIncludedInPrice: boolean;
473
+ taxRate: number;
474
+ promoDiscountAmount: number;
475
+ discountLabel?: string | null;
476
+ } | null>(null);
477
+ // Get all active product options (memoized to prevent recreating array on each render)
478
+ const activeOptions = useMemo(() =>
479
+ product.options?.filter(opt => opt.status === 'ACTIVE') || [],
480
+ [product.options]
481
+ );
482
+
483
+ // Detect if this is a Private Shuttle product
484
+ const isPrivateShuttle = product.productType === 'PRIVATE_SHUTTLE';
485
+
486
+ // Create stable string key from option IDs for dependency array
487
+ const activeOptionIdsKey = useMemo(() =>
488
+ activeOptions.map(opt => opt.optionId).sort().join(','),
489
+ [activeOptions]
490
+ );
491
+
492
+ // Create a Map for O(1) option lookups by optionId (performance optimization)
493
+ const optionsMap = useMemo(() => {
494
+ const map = new Map<string, typeof activeOptions[0]>();
495
+ activeOptions.forEach(opt => map.set(opt.optionId, opt));
496
+ return map;
497
+ }, [activeOptions]);
498
+
499
+ // Fire view_item when product is first displayed
500
+ const hasFiredViewItem = useRef(false);
501
+ useEffect(() => {
502
+ if (!hasFiredViewItem.current && product) {
503
+ hasFiredViewItem.current = true;
504
+ const id = productId || product.productId;
505
+ const price = product.minPriceByCurrency?.[currency] ?? 0;
506
+ analytics.trackViewItem(id, product.name, price, currency);
507
+ }
508
+ }, [product, productId, currency]);
509
+
510
+ // Helper function to check if we need to fetch a date range
511
+ const needsFetch = (start: Date, end: Date): boolean => {
512
+ if (fetchedRangesRef.current.length === 0) return true;
513
+
514
+ // Check if the requested range is fully covered by fetched ranges
515
+ // For simplicity, check if any single fetched range fully covers the requested range
516
+ return !fetchedRangesRef.current.some(range => {
517
+ const rangeStart = range.start.getTime();
518
+ const rangeEnd = range.end.getTime();
519
+ const reqStart = start.getTime();
520
+ const reqEnd = end.getTime();
521
+
522
+ // Check if this fetched range fully covers the requested range
523
+ return rangeStart <= reqStart && rangeEnd >= reqEnd;
524
+ });
525
+ };
526
+
527
+ /** Re-fetch current calendar window so vacancies/booked counts match server after a reserve race. */
528
+ const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
529
+ if (isPartialLaunch || !visibleRange || activeOptions.length === 0) {
530
+ return [];
531
+ }
532
+
533
+ const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
534
+ const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
535
+ const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
536
+ ? startOfEarliestDay
537
+ : visibleRange.start;
538
+ let clampedEnd = isAfter(visibleRange.end, endOfLatestDay) ? endOfLatestDay : visibleRange.end;
539
+
540
+ if (selectedDate) {
541
+ try {
542
+ const selectedDateObj = parseISO(selectedDate);
543
+ if (isAfter(selectedDateObj, clampedEnd)) {
544
+ clampedEnd = selectedDateObj;
545
+ }
546
+ } catch {
547
+ /* ignore */
548
+ }
549
+ }
550
+
551
+ let startDateStr: string;
552
+ let endDateStr: string;
553
+
554
+ if (isPrivateShuttle) {
555
+ startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
556
+ endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
557
+ } else {
558
+ const startDateInTz = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
559
+ const endDateInTz = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
560
+ const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
561
+ const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
562
+ startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
563
+ endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
564
+ }
565
+
566
+ const availabilityPromises = activeOptions.map(async (option) => {
567
+ const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
568
+ promoCode: appliedPromoCode || undefined,
569
+ ...(pricingProfileIdForAvailabilities
570
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
571
+ : {}),
572
+ ...(cancellationPolicyProfileIdForAvailabilities
573
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
574
+ : {}),
575
+ });
576
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
577
+ setPricingConfig((prev) => {
578
+ if (!prev) {
579
+ pricingConfigSetRef.current = true;
580
+ return result.pricingConfig!;
581
+ }
582
+ return prev;
583
+ });
584
+ }
585
+ return {
586
+ optionId: option.optionId,
587
+ availabilities: result.availabilities.map((avail) => ({
588
+ ...avail,
589
+ productOptionId: option.optionId,
590
+ })),
591
+ precomputedPrices: result.precomputedPrices,
592
+ pricingConfig: result.pricingConfig,
593
+ };
594
+ });
595
+
596
+ const results = await Promise.all(availabilityPromises);
597
+ const allFetchedAvailabilities = results.flatMap((r) => r.availabilities);
598
+
599
+ setPrecomputedPricesByOption((prev) => {
600
+ const next = { ...(prev || {}) };
601
+ results.forEach((r) => {
602
+ if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
603
+ next[r.optionId] = r.precomputedPrices;
604
+ }
605
+ });
606
+ return Object.keys(next).length > 0 ? next : prev;
607
+ });
608
+
609
+ let mergedOut: Availability[] = [];
610
+ setAvailabilities((prev) => {
611
+ const existingMap = new Map(
612
+ prev.map((avail) => [`${avail.dateTime}-${avail.productOptionId}`, avail])
613
+ );
614
+ allFetchedAvailabilities.forEach((avail) => {
615
+ existingMap.set(`${avail.dateTime}-${avail.productOptionId}`, avail);
616
+ });
617
+ mergedOut = Array.from(existingMap.values());
618
+ return mergedOut;
619
+ });
620
+
621
+ fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
622
+ fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
623
+ const mergedRanges: Array<{ start: Date; end: Date }> = [];
624
+ for (const r of fetchedRangesRef.current) {
625
+ if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].end < r.start) {
626
+ mergedRanges.push({ start: r.start, end: r.end });
627
+ } else {
628
+ mergedRanges[mergedRanges.length - 1].end =
629
+ r.end > mergedRanges[mergedRanges.length - 1].end
630
+ ? r.end
631
+ : mergedRanges[mergedRanges.length - 1].end;
632
+ }
633
+ }
634
+ fetchedRangesRef.current = mergedRanges;
635
+
636
+ const cacheKey = availabilitiesCache
637
+ ? buildAvailabilitiesCacheKey(
638
+ product.productId,
639
+ activeOptionIdsKey,
640
+ appliedPromoCode,
641
+ pricingProfileIdForAvailabilities,
642
+ )
643
+ : null;
644
+ if (cacheKey && availabilitiesCache) {
645
+ const existingCache = availabilitiesCache.get(cacheKey);
646
+ const existingAvailabilities = existingCache?.availabilities ?? [];
647
+ const mergedAvailabilitiesMap = new Map(
648
+ existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
649
+ );
650
+ allFetchedAvailabilities.forEach((a) => {
651
+ mergedAvailabilitiesMap.set(`${a.dateTime}-${a.productOptionId}`, a);
652
+ });
653
+ const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
654
+ results.forEach((r) => {
655
+ if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
656
+ mergedPrecomputed[r.optionId] = r.precomputedPrices;
657
+ }
658
+ });
659
+ const firstPricingConfig =
660
+ (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ??
661
+ existingCache?.pricingConfig ??
662
+ null;
663
+ availabilitiesCache.merge(cacheKey, {
664
+ fetchedRanges: mergedRanges,
665
+ availabilities: Array.from(mergedAvailabilitiesMap.values()),
666
+ pricingConfig: firstPricingConfig,
667
+ precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
668
+ });
669
+ }
670
+
671
+ return mergedOut;
672
+ }, [
673
+ isPartialLaunch,
674
+ visibleRange,
675
+ companyTimezone,
676
+ selectedDate,
677
+ isPrivateShuttle,
678
+ activeOptions,
679
+ appliedPromoCode,
680
+ pricingProfileIdForAvailabilities,
681
+ cancellationPolicyProfileIdForAvailabilities,
682
+ product.productId,
683
+ activeOptionIdsKey,
684
+ availabilitiesCache,
685
+ ]);
686
+
687
+ // Initialize visible range when unset (anchor at season open for fast first paint).
688
+ useEffect(() => {
689
+ if (!visibleRange) {
690
+ const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS);
691
+ setVisibleRange({ start: EARLIEST_AVAILABILITY_DATE, end: initialEnd });
692
+ }
693
+ }, [visibleRange]);
694
+
695
+ // Fetch availabilities for visible range + buffer
696
+ useEffect(() => {
697
+ if (isPartialLaunch) {
698
+ setLoadingAvailabilities(false);
699
+ return;
700
+ }
701
+ if (activeOptions.length === 0) {
702
+ setError('No active product options available');
703
+ setLoadingAvailabilities(false);
704
+ return;
705
+ }
706
+
707
+ if (!visibleRange) {
708
+ // Wait for initial range to be set
709
+ return;
710
+ }
711
+
712
+ async function fetchAvailabilities() {
713
+ // Prevent concurrent fetches - store range to fetch when current one completes
714
+ if (fetchingRef.current && visibleRange) {
715
+ const inFlight = inFlightRangeRef.current;
716
+ if (
717
+ inFlight &&
718
+ inFlight.start.getTime() === visibleRange.start.getTime() &&
719
+ inFlight.end.getTime() === visibleRange.end.getTime()
720
+ ) {
721
+ return;
722
+ }
723
+ pendingRangeRef.current = { start: visibleRange.start, end: visibleRange.end };
724
+ return;
725
+ }
726
+
727
+ // Clamp to available date range (use company timezone - date-fns startOfDay/endOfDay use local TZ which can exclude Oct 12)
728
+ // For end date, we need end of Oct 12 in company timezone (inclusive)
729
+ if (!visibleRange) return;
730
+
731
+ const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
732
+ const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
733
+ const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
734
+ ? startOfEarliestDay
735
+ : visibleRange.start;
736
+ let clampedEnd = isAfter(visibleRange.end, endOfLatestDay)
737
+ ? endOfLatestDay
738
+ : visibleRange.end;
739
+
740
+ // Ensure we include the selected date if it's after the visible range end
741
+ // This handles the case where user selects a date that's not yet in the visible range
742
+ if (selectedDate) {
743
+ try {
744
+ const selectedDateObj = parseISO(selectedDate);
745
+ if (isAfter(selectedDateObj, clampedEnd)) {
746
+ clampedEnd = selectedDateObj;
747
+ }
748
+ } catch {
749
+ // Ignore parse errors
750
+ }
751
+ }
752
+
753
+ // Check cache first - avoid refetch when reopening same product
754
+ const cacheKey = availabilitiesCache
755
+ ? buildAvailabilitiesCacheKey(
756
+ product.productId,
757
+ activeOptionIdsKey,
758
+ appliedPromoCode,
759
+ pricingProfileIdForAvailabilities,
760
+ )
761
+ : null;
762
+ const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
763
+ if (cached && cached.availabilities.length > 0) {
764
+ const cachedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(cached.availabilities);
765
+ if (cachedInitialQuantities) {
766
+ setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : cachedInitialQuantities));
767
+ }
768
+ const cacheCoversRange = cached.fetchedRanges.some(
769
+ (r) => r.start.getTime() <= clampedStart.getTime() && r.end.getTime() >= clampedEnd.getTime()
770
+ );
771
+ const isStale = availabilitiesCache?.isStale(cached) ?? false;
772
+ if (cacheCoversRange) {
773
+ setAvailabilities(cached.availabilities);
774
+ if (cached.availabilities.length > 0) {
775
+ hasLoadedAvailabilitiesRef.current = true;
776
+ }
777
+ if (cached.pricingConfig) {
778
+ setPricingConfig(cached.pricingConfig);
779
+ pricingConfigSetRef.current = true;
780
+ }
781
+ if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
782
+ setPrecomputedPricesByOption(cached.precomputedPricesByOption);
783
+ }
784
+ setLoadingAvailabilities(false);
785
+ setIsFetchingMoreAvailabilities(false);
786
+ if (!isStale) {
787
+ fetchedRangesRef.current = [...cached.fetchedRanges];
788
+ fetchingRef.current = false;
789
+ return;
790
+ }
791
+ // Stale-while-revalidate: show cached data, fetch in background (don't set fetchedRangesRef so we fall through)
792
+ }
793
+ // Partial cache: show cached data immediately, then fetch missing range below
794
+ setAvailabilities(cached.availabilities);
795
+ if (cached.availabilities.length > 0) {
796
+ hasLoadedAvailabilitiesRef.current = true;
797
+ }
798
+ if (cached.pricingConfig) {
799
+ setPricingConfig(cached.pricingConfig);
800
+ pricingConfigSetRef.current = true;
801
+ }
802
+ if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
803
+ setPrecomputedPricesByOption(cached.precomputedPricesByOption);
804
+ }
805
+ fetchedRangesRef.current = [...cached.fetchedRanges];
806
+ setLoadingAvailabilities(false);
807
+ }
808
+
809
+ // Check if we need to fetch this range
810
+ // Backend always returns CAD prices without fee/tax, so no need to refetch on currency change
811
+ const shouldFetch = needsFetch(clampedStart, clampedEnd);
812
+ if (!shouldFetch) {
813
+ // Range already fetched - ensure loading state is cleared
814
+ setLoadingAvailabilities(false);
815
+ setIsFetchingMoreAvailabilities(false);
816
+ fetchingRef.current = false;
817
+ return;
818
+ }
819
+
820
+ const hasPartialCache = cached && cached.availabilities.length > 0;
821
+ const shouldUsePrimaryLoader =
822
+ !hasPartialCache && !hasLoadedAvailabilitiesRef.current;
823
+ fetchingRef.current = true;
824
+ inFlightRangeRef.current = {
825
+ start: new Date(clampedStart),
826
+ end: new Date(clampedEnd),
827
+ };
828
+ if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
829
+ else setIsFetchingMoreAvailabilities(true);
830
+
831
+ try {
832
+ let startDateStr: string;
833
+ let endDateStr: string;
834
+
835
+ if (isPrivateShuttle) {
836
+ // Private Shuttle: use date-only format (YYYY-MM-DD)
837
+ startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
838
+ // Use endOfDay to include the full last day
839
+ endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
840
+ } else {
841
+ // Standard products: API expects UTC. Get full first/last days in company TZ, then format as UTC.
842
+ const startDateInTz = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
843
+ const endDateInTz = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
844
+ const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
845
+ const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
846
+ startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
847
+ endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
848
+ }
849
+
850
+ // Fetch availabilities for all product options in parallel (no currency param - we use precomputedPrices for display)
851
+ const availabilityPromises = activeOptions.map(async (option) => {
852
+ const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
853
+ promoCode: appliedPromoCode || undefined,
854
+ ...(pricingProfileIdForAvailabilities
855
+ ? { pricingProfileId: pricingProfileIdForAvailabilities }
856
+ : {}),
857
+ ...(cancellationPolicyProfileIdForAvailabilities
858
+ ? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
859
+ : {}),
860
+ });
861
+ // Store pricing config from first response only (all responses have same config)
862
+ if (result.pricingConfig && !pricingConfigSetRef.current) {
863
+ setPricingConfig(prev => {
864
+ if (!prev) {
865
+ pricingConfigSetRef.current = true;
866
+ return result.pricingConfig!;
867
+ }
868
+ return prev;
869
+ });
870
+ }
871
+ // Tag each availability with its productOptionId and carry precomputedPrices for this option
872
+ return {
873
+ optionId: option.optionId,
874
+ availabilities: result.availabilities.map(avail => ({
875
+ ...avail,
876
+ productOptionId: option.optionId
877
+ })),
878
+ precomputedPrices: result.precomputedPrices,
879
+ pricingConfig: result.pricingConfig,
880
+ };
881
+ });
882
+
883
+ const results = await Promise.all(availabilityPromises);
884
+ const allFetchedAvailabilities = results.flatMap(r => r.availabilities);
885
+ if (allFetchedAvailabilities.length > 0) {
886
+ hasLoadedAvailabilitiesRef.current = true;
887
+ }
888
+ setPrecomputedPricesByOption(prev => {
889
+ const next = { ...(prev || {}) };
890
+ results.forEach(r => {
891
+ if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
892
+ next[r.optionId] = r.precomputedPrices;
893
+ }
894
+ });
895
+ return Object.keys(next).length > 0 ? next : prev;
896
+ });
897
+
898
+ // Merge with existing availabilities (avoid duplicates by dateTime + productOptionId)
899
+ setAvailabilities(prev => {
900
+ const existingMap = new Map(
901
+ prev.map(avail => [`${avail.dateTime}-${avail.productOptionId}`, avail])
902
+ );
903
+
904
+ // Merge new availabilities - update existing ones or add new ones
905
+ allFetchedAvailabilities.forEach(avail => {
906
+ const key = `${avail.dateTime}-${avail.productOptionId}`;
907
+ // Always update to get latest data (vacancies, prices, etc.)
908
+ existingMap.set(key, avail);
909
+ });
910
+
911
+ return Array.from(existingMap.values());
912
+ });
913
+
914
+ // Mark this range as fetched
915
+ fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
916
+ // Sort and merge overlapping ranges
917
+ fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
918
+ const merged: Array<{ start: Date; end: Date }> = [];
919
+ for (const r of fetchedRangesRef.current) {
920
+ if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
921
+ merged.push({ start: r.start, end: r.end });
922
+ } else {
923
+ merged[merged.length - 1].end = r.end > merged[merged.length - 1].end
924
+ ? r.end
925
+ : merged[merged.length - 1].end;
926
+ }
927
+ }
928
+ fetchedRangesRef.current = merged;
929
+
930
+ // Update cache for instant load when reopening same product
931
+ if (cacheKey && availabilitiesCache) {
932
+ const existingCache = availabilitiesCache.get(cacheKey);
933
+ const existingAvailabilities = existingCache?.availabilities ?? [];
934
+ const mergedAvailabilitiesMap = new Map(
935
+ existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
936
+ );
937
+ allFetchedAvailabilities.forEach((a) => {
938
+ mergedAvailabilitiesMap.set(`${a.dateTime}-${a.productOptionId}`, a);
939
+ });
940
+ const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
941
+ results.forEach((r) => {
942
+ if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
943
+ mergedPrecomputed[r.optionId] = r.precomputedPrices;
944
+ }
945
+ });
946
+ const firstPricingConfig = (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ?? existingCache?.pricingConfig ?? null;
947
+ availabilitiesCache.merge(cacheKey, {
948
+ fetchedRanges: merged,
949
+ availabilities: Array.from(mergedAvailabilitiesMap.values()),
950
+ pricingConfig: firstPricingConfig,
951
+ precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
952
+ });
953
+ }
954
+
955
+ // Initialize quantities based on first availability's categories (only if not already set)
956
+ // Use functional update to avoid dependency on quantities state
957
+ const fetchedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(allFetchedAvailabilities);
958
+ if (fetchedInitialQuantities) {
959
+ setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : fetchedInitialQuantities));
960
+ }
961
+ } catch (err) {
962
+ setError(err instanceof Error ? err.message : 'Failed to load availabilities');
963
+ console.error('Error fetching availabilities:', err);
964
+ } finally {
965
+ setLoadingAvailabilities(false);
966
+ setIsFetchingMoreAvailabilities(false);
967
+ fetchingRef.current = false;
968
+ inFlightRangeRef.current = null;
969
+ // If user navigated during fetch, trigger fetch for the pending range
970
+ const pending = pendingRangeRef.current;
971
+ if (pending) {
972
+ pendingRangeRef.current = null;
973
+ setVisibleRange({ start: pending.start, end: pending.end });
974
+ }
975
+ }
976
+ }
977
+
978
+ fetchAvailabilities();
979
+ // Use activeOptionIdsKey only — activeOptions is a new array reference when product.options identity changes and would refetch in a tight loop.
980
+ }, [
981
+ isPartialLaunch,
982
+ visibleRange,
983
+ activeOptionIdsKey,
984
+ isPrivateShuttle,
985
+ companyTimezone,
986
+ selectedDate,
987
+ appliedPromoCode,
988
+ pricingProfileIdForAvailabilities,
989
+ cancellationPolicyProfileIdForAvailabilities,
990
+ ]);
991
+
992
+ // When promo or partner pricing profile changes, clear fetched ranges so we refetch with new pricing
993
+ useEffect(() => {
994
+ fetchedRangesRef.current = [];
995
+ }, [
996
+ appliedPromoCode,
997
+ pricingProfileIdForAvailabilities,
998
+ cancellationPolicyProfileIdForAvailabilities,
999
+ ]);
1000
+
1001
+ // Memoized callback for visible range changes
1002
+ // Only update if the range actually changed to avoid unnecessary fetches
1003
+ const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
1004
+ const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
1005
+ const lastRange = lastVisibleRangeRef.current;
1006
+ // Update if this is the first range or if it changed significantly (more than a day)
1007
+ const rangeChanged = !lastRange ||
1008
+ Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
1009
+ Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
1010
+
1011
+ if (rangeChanged) {
1012
+ lastVisibleRangeRef.current = { start, end };
1013
+ // Always update state to trigger fetch, even if needsFetch might return false
1014
+ // The needsFetch check will prevent unnecessary API calls, but we want the state update
1015
+ setVisibleRange({ start, end });
1016
+ }
1017
+ }, []);
1018
+
1019
+ // Group availabilities by date (in company timezone) and sort by time
1020
+ // Memoized to prevent recalculation on every render
1021
+ const availabilitiesByDate = useMemo(() => {
1022
+ const grouped = availabilities.reduce((acc, avail) => {
1023
+ // Parse the dateTime and extract the date in company timezone
1024
+ const dateTime = parseAvailabilityDateTime(avail.dateTime);
1025
+ const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
1026
+ if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
1027
+ acc[dateInCompanyTz].push(avail);
1028
+ return acc;
1029
+ }, {} as Record<string, Availability[]>);
1030
+
1031
+ // Sort availabilities within each date by time (create new sorted arrays, don't mutate)
1032
+ const sorted: Record<string, Availability[]> = {};
1033
+ Object.keys(grouped).forEach(date => {
1034
+ sorted[date] = [...grouped[date]].sort((a, b) => {
1035
+ const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
1036
+ const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
1037
+ return timeA - timeB;
1038
+ });
1039
+ });
1040
+
1041
+ return sorted;
1042
+ }, [availabilities, companyTimezone]);
1043
+
1044
+ const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
1045
+
1046
+ // Track mobile state
1047
+ useEffect(() => {
1048
+ const checkMobile = () => {
1049
+ setIsMobile(window.innerWidth < 640); // sm breakpoint
1050
+ };
1051
+ checkMobile();
1052
+ window.addEventListener('resize', checkMobile);
1053
+ return () => window.removeEventListener('resize', checkMobile);
1054
+ }, []);
1055
+
1056
+ // Close tooltip when clicking outside
1057
+ useEffect(() => {
1058
+ if (!showTooltip) return;
1059
+
1060
+ const handleClickOutside = (e: MouseEvent | TouchEvent) => {
1061
+ const target = e.target as HTMLElement;
1062
+ if (!target.closest('[data-tooltip-icon]')) {
1063
+ setShowTooltip(false);
1064
+ }
1065
+ };
1066
+
1067
+ document.addEventListener('mousedown', handleClickOutside);
1068
+ document.addEventListener('touchstart', handleClickOutside);
1069
+
1070
+ return () => {
1071
+ document.removeEventListener('mousedown', handleClickOutside);
1072
+ document.removeEventListener('touchstart', handleClickOutside);
1073
+ };
1074
+ }, [showTooltip]);
1075
+
1076
+ // Detect when itinerary box becomes sticky
1077
+ // In viavia the scroll usually happens inside the dialog content div, not window.
1078
+ // On full-page partner layouts, we instead listen to window scroll (useWindowScroll = true).
1079
+ const lastStickyChangeRef = useRef<number>(0);
1080
+ useEffect(() => {
1081
+ const el = itineraryRef.current;
1082
+ if (!el) return;
1083
+
1084
+ const findScrollParent = (node: HTMLElement): HTMLElement | null => {
1085
+ let parent = node.parentElement;
1086
+ while (parent) {
1087
+ const { overflowY } = getComputedStyle(parent);
1088
+ if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return parent;
1089
+ parent = parent.parentElement;
1090
+ }
1091
+ return null;
1092
+ };
1093
+
1094
+ const scrollParent = findScrollParent(el);
1095
+ const scrollTarget =
1096
+ useWindowScroll || !scrollParent
1097
+ ? (typeof window !== 'undefined' ? window : null)
1098
+ : scrollParent;
1099
+
1100
+ let ticking = false;
1101
+ const COOLDOWN_MS = 600; // After a state change, ignore reverse changes for this long (covers 0.25s collapse animation + layout settle)
1102
+ const atTopBand = 48; // px - must scroll back up past this band to expand again (wider = less oscillation at edges)
1103
+
1104
+ const updateStickyState = () => {
1105
+ if (!itineraryRef.current) return;
1106
+
1107
+ const rect = itineraryRef.current.getBoundingClientRect();
1108
+ const currentTop = rect.top;
1109
+ const wasSticky = isItineraryStickyRef.current;
1110
+
1111
+ const containerTop =
1112
+ scrollParent && !useWindowScroll ? scrollParent.getBoundingClientRect().top : 0;
1113
+ const topInset = Math.max(0, flowUi?.itineraryStickyTopOffsetPx ?? 0);
1114
+ const stickLine = containerTop + topInset;
1115
+ const enterStickyThreshold = stickLine + 1;
1116
+ const nextSticky = wasSticky
1117
+ ? currentTop >= stickLine - atTopBand && currentTop <= stickLine + atTopBand
1118
+ : currentTop <= enterStickyThreshold;
1119
+
1120
+ if (nextSticky !== wasSticky) {
1121
+ const now = Date.now();
1122
+ if (now - lastStickyChangeRef.current < COOLDOWN_MS) return; // Cooldown: prevent rapid toggling
1123
+
1124
+ lastStickyChangeRef.current = now;
1125
+ isItineraryStickyRef.current = nextSticky;
1126
+ setIsItinerarySticky(nextSticky);
1127
+ }
1128
+ };
1129
+
1130
+ const handleScroll = () => {
1131
+ if (!ticking) {
1132
+ window.requestAnimationFrame(() => {
1133
+ updateStickyState();
1134
+ ticking = false;
1135
+ });
1136
+ ticking = true;
1137
+ }
1138
+ };
1139
+
1140
+ if (scrollTarget) {
1141
+ scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1142
+ updateStickyState();
1143
+ return () => scrollTarget.removeEventListener('scroll', handleScroll);
1144
+ }
1145
+ return undefined;
1146
+ }, [selectedDate, selectedAvailability, useWindowScroll, flowUi?.itineraryStickyTopOffsetPx]); // Re-check when itinerary / scroll mode / host chrome changes
1147
+
1148
+ // Find the earliest availability date - memoize with a stable reference
1149
+ // Only recalculate if we don't have a cached value or if the new earliest is actually earlier
1150
+ // IMPORTANT: Never return null once we have a value, to prevent calendar reset during loading
1151
+ const earliestAvailabilityDateRef = useRef<Date | null>(null);
1152
+ const earliestAvailabilityDate = useMemo(() => {
1153
+ if (dates.length === 0) {
1154
+ // If we have a cached value, keep using it even during loading
1155
+ return earliestAvailabilityDateRef.current || EARLIEST_AVAILABILITY_DATE;
1156
+ }
1157
+ const firstDate = dates[0];
1158
+ const firstAvail = availabilitiesByDate[firstDate]?.[0];
1159
+ let newEarliest: Date;
1160
+ if (firstAvail) {
1161
+ newEarliest = parseISO(firstAvail.dateTime);
1162
+ } else {
1163
+ // Fallback: parse the date string
1164
+ // Use company timezone noon to avoid one-day shifts for users in other timezones.
1165
+ newEarliest = fromZonedTime(parseISO(`${firstDate}T12:00:00`), companyTimezone);
1166
+ }
1167
+
1168
+ // Only update if we don't have a cached value or if the new one is earlier
1169
+ if (!earliestAvailabilityDateRef.current || newEarliest < earliestAvailabilityDateRef.current) {
1170
+ earliestAvailabilityDateRef.current = newEarliest;
1171
+ }
1172
+
1173
+ return earliestAvailabilityDateRef.current;
1174
+ }, [dates, availabilitiesByDate]);
1175
+
1176
+ const timesForSelectedDate = useMemo(() => {
1177
+ return selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
1178
+ }, [selectedDate, availabilitiesByDate]);
1179
+ const timesForSelectedDateSelectionKey = useMemo(
1180
+ () =>
1181
+ timesForSelectedDate
1182
+ .map(
1183
+ (avail) =>
1184
+ `${avail.dateTime}|${avail.productOptionId ?? ''}|${avail.vacancies ?? 0}`,
1185
+ )
1186
+ .join('||'),
1187
+ [timesForSelectedDate],
1188
+ );
1189
+
1190
+ useEffect(() => {
1191
+ if (hasAppliedInitialValuesRef.current || !initialValues) return;
1192
+ const trimmedEmail = initialValues.customer?.email?.trim();
1193
+ const trimmedFirstName = initialValues.customer?.firstName?.trim();
1194
+ const trimmedLastName = initialValues.customer?.lastName?.trim();
1195
+ const trimmedPromo = initialValues.promoCode?.trim().toUpperCase();
1196
+ if (trimmedEmail) setEmail(trimmedEmail);
1197
+ if (trimmedFirstName) setFirstName(trimmedFirstName);
1198
+ if (trimmedLastName) setLastName(trimmedLastName);
1199
+ if (initialValues.pickupLocationId?.trim()) {
1200
+ setPickupLocationId(initialValues.pickupLocationId.trim());
1201
+ setPickupLocationSkipped(false);
1202
+ }
1203
+ if (trimmedPromo) {
1204
+ setPromoCodeInput(trimmedPromo);
1205
+ setAppliedPromoCode(trimmedPromo);
1206
+ }
1207
+ hasAppliedInitialValuesRef.current = true;
1208
+ }, [initialValues]);
1209
+
1210
+ /** Partner/embed: pre-select calendar day from URL/marketing payload; time slot comes from auto-select below. */
1211
+ useEffect(() => {
1212
+ if (!initialValues?.dateTime || selectedAvailability) return;
1213
+ const target = parseAvailabilityDateTime(initialValues.dateTime);
1214
+ const targetDate = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
1215
+ if (!selectedDate) {
1216
+ setSelectedDate(targetDate);
1217
+ }
1218
+ }, [initialValues?.dateTime, selectedAvailability, selectedDate, companyTimezone]);
1219
+
1220
+ useEffect(() => {
1221
+ if (
1222
+ hasAppliedInitialQuantitiesRef.current ||
1223
+ !initialValues?.bookingItems ||
1224
+ initialValues.bookingItems.length === 0 ||
1225
+ !selectedAvailability
1226
+ ) {
1227
+ return;
1228
+ }
1229
+ const next: Record<string, number> = {};
1230
+ for (const item of initialValues.bookingItems) {
1231
+ const key = item.category?.trim();
1232
+ if (!key) continue;
1233
+ next[key] = Math.max(0, Number(item.count) || 0);
1234
+ }
1235
+ if (Object.keys(next).length > 0) {
1236
+ setQuantities((prev) => ({ ...prev, ...next }));
1237
+ hasAppliedInitialQuantitiesRef.current = true;
1238
+ }
1239
+ }, [initialValues, selectedAvailability]);
1240
+
1241
+ const applyChangeFlowAddOnFloor = useCallback(
1242
+ (nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => nextSelections,
1243
+ []
1244
+ );
1245
+
1246
+ const updateAddOnSelections = useCallback(
1247
+ (
1248
+ updater:
1249
+ | Array<{ addOnId: string; variantId?: string; quantity?: number }>
1250
+ | ((prev: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => Array<{ addOnId: string; variantId?: string; quantity?: number }>)
1251
+ ) => {
1252
+ setAddOnSelections((prev) => {
1253
+ const rawNext = typeof updater === 'function' ? updater(prev) : updater;
1254
+ return applyChangeFlowAddOnFloor(rawNext);
1255
+ });
1256
+ },
1257
+ [applyChangeFlowAddOnFloor]
1258
+ );
1259
+
1260
+ // Get selected pickup location
1261
+ const selectedPickupLocation = useMemo(() =>
1262
+ product.pickupLocations?.find(loc => loc.id === pickupLocationId),
1263
+ [product.pickupLocations, pickupLocationId]
1264
+ );
1265
+
1266
+ // Calculate maximum time offset from all pickup locations (for range display when location is unknown)
1267
+ const maxTimeOffsetMinutes = useMemo(() => {
1268
+ if (!product.pickupLocations || product.pickupLocations.length === 0) {
1269
+ return 0;
1270
+ }
1271
+ return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
1272
+ }, [product.pickupLocations]);
1273
+ // Calculate pickup times based on availability times + pickup location offset
1274
+ interface PickupTimeInfo extends Availability {
1275
+ pickupTime: string;
1276
+ displayTime: string;
1277
+ originalTime: string;
1278
+ displayTimeRange?: string; // Time range when pickup location is unknown
1279
+ }
1280
+
1281
+ const pickupTimes = useMemo((): PickupTimeInfo[] => {
1282
+ if (!selectedDate || !timesForSelectedDate.length) {
1283
+ return [];
1284
+ }
1285
+
1286
+ // Show base availability times (without pickup location offset) when pickup location not selected yet
1287
+ // Once pickup location is selected, we can show adjusted times
1288
+ const offsetMinutes = pickupLocationSkipped
1289
+ ? 0
1290
+ : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
1291
+
1292
+ return timesForSelectedDate.map(avail => {
1293
+ // Parse the dateTime (which should already be in company timezone from backend)
1294
+ const availabilityTime = parseISO(avail.dateTime);
1295
+ const adjustedVacancies = Math.max(0, avail.vacancies ?? 0);
1296
+
1297
+ // Only apply offset if it's set and > 0 and location is selected
1298
+ const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
1299
+ ? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
1300
+ : availabilityTime;
1301
+
1302
+ // Format in company timezone (not user's local timezone)
1303
+ const displayTime = formatInTimeZone(pickupTime, companyTimezone, 'h:mm a');
1304
+ const originalTime = formatInTimeZone(availabilityTime, companyTimezone, 'h:mm a');
1305
+
1306
+ // If pickup location is skipped, calculate and display time range
1307
+ let displayTimeRange: string | undefined;
1308
+ if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
1309
+ const latestPickupTime = new Date(availabilityTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
1310
+ const latestTimeStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
1311
+ displayTimeRange = `${originalTime} - ${latestTimeStr}`;
1312
+ }
1313
+
1314
+ return {
1315
+ ...avail,
1316
+ vacancies: adjustedVacancies,
1317
+ pickupTime: pickupTime.toISOString(),
1318
+ displayTime,
1319
+ originalTime,
1320
+ displayTimeRange,
1321
+ };
1322
+ });
1323
+ }, [
1324
+ selectedDate,
1325
+ selectedPickupLocation,
1326
+ timesForSelectedDate,
1327
+ pickupLocationSkipped,
1328
+ maxTimeOffsetMinutes,
1329
+ companyTimezone,
1330
+ ]);
1331
+
1332
+ // Check if any pickup time has "most popular" tag (memoized for performance)
1333
+ const hasAnyMostPopular = useMemo(() => {
1334
+ if (pickupTimes.length <= 1) return false;
1335
+ return pickupTimes.some(t => {
1336
+ if (!t.productOptionId) return false;
1337
+ const opt = optionsMap.get(t.productOptionId);
1338
+ return opt?.mostPopular;
1339
+ });
1340
+ }, [pickupTimes, optionsMap]);
1341
+
1342
+ // Helper function to get effective itinerary based on selected date and overrides
1343
+ const getEffectiveItinerary = useCallback((option: typeof activeOptions[0], date: Date): typeof option.itinerary => {
1344
+ if (!option.itinerary) return undefined;
1345
+
1346
+ const monthDay = formatInTimeZone(date, companyTimezone, 'MM-dd');
1347
+
1348
+ // Check for date-specific override
1349
+ if (option.itineraryOverrides && option.itineraryOverrides.length > 0) {
1350
+ for (const override of option.itineraryOverrides) {
1351
+ if (monthDay >= override.startDate && monthDay <= override.endDate) {
1352
+ return override.itinerary;
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ return option.itinerary;
1358
+ }, [companyTimezone]);
1359
+
1360
+ // Helper function to calculate stay summary for a specific return option
1361
+ const calculateStaySummary = useCallback((returnDateTime: Date): string | null => {
1362
+ if (!selectedAvailability) return null;
1363
+
1364
+ const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
1365
+ const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
1366
+ if (!selectedOption) return null;
1367
+
1368
+ const tourStartTime = parseISO(selectedAvailability.dateTime);
1369
+ const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
1370
+ const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
1371
+
1372
+ if (!hasItinerary || !product.destinations || !itinerary) return null;
1373
+
1374
+ const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
1375
+ let lastDepartureTime = tourStartTime;
1376
+
1377
+ const stays: Array<{ destinationName: string; durationHours: number }> = [];
1378
+
1379
+ itinerary.forEach((itineraryItem, index) => {
1380
+ const destination = destinationMap.get(itineraryItem.destinationName);
1381
+ if (!destination) return;
1382
+
1383
+ const arrivalTime = new Date(
1384
+ lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000)
1385
+ );
1386
+
1387
+ const isLastDestination = index === itinerary.length - 1;
1388
+
1389
+ if (isLastDestination) {
1390
+ // For the last destination, calculate time from arrival to return time
1391
+ const timeDiffMs = returnDateTime.getTime() - arrivalTime.getTime();
1392
+ const timeDiffHours = timeDiffMs / (1000 * 60 * 60);
1393
+
1394
+ if (timeDiffHours > 0) {
1395
+ stays.push({
1396
+ destinationName: destination.name,
1397
+ durationHours: timeDiffHours,
1398
+ });
1399
+ }
1400
+ } else {
1401
+ // For non-last destinations (including first), use durationHours only if there's more than one stop
1402
+ if (itinerary.length > 1 && itineraryItem.durationHours && itineraryItem.durationHours > 0) {
1403
+ stays.push({
1404
+ destinationName: destination.name,
1405
+ durationHours: itineraryItem.durationHours,
1406
+ });
1407
+ }
1408
+ }
1409
+
1410
+ const departureTime = itineraryItem.durationHours && !isLastDestination
1411
+ ? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
1412
+ : arrivalTime;
1413
+
1414
+ lastDepartureTime = departureTime;
1415
+ });
1416
+
1417
+ if (stays.length === 0) return null;
1418
+
1419
+ // Build summary string e.g. "2 hours at [destination] + 2 hours at [destination]"
1420
+ return stays.map(stay => {
1421
+ const hours = Math.floor(stay.durationHours);
1422
+ const minutes = Math.round((stay.durationHours - hours) * 60);
1423
+ let timeStr = '';
1424
+ if (hours === 0 && minutes === 0) {
1425
+ timeStr = '0 min';
1426
+ } else if (hours === 0) {
1427
+ timeStr = `${minutes} min`;
1428
+ } else if (minutes === 0) {
1429
+ timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
1430
+ } else {
1431
+ timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${minutes} min`;
1432
+ }
1433
+ return `${timeStr} at ${stay.destinationName}`;
1434
+ }).join(' + ');
1435
+ }, [selectedAvailability, activeOptions, product.destinations, getEffectiveItinerary]);
1436
+
1437
+ // Helper function to compute itinerary display for storage (returns same shape as "Your Itinerary" box)
1438
+ const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
1439
+ if (!selectedAvailability) return null;
1440
+ const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
1441
+ const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
1442
+ if (!selectedOption) return null;
1443
+ const tourStartTime = parseISO(selectedAvailability.dateTime);
1444
+ const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
1445
+ const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
1446
+ if (!hasItinerary || !product.destinations || !itinerary) return null;
1447
+
1448
+ const itineraryItems: ItineraryDisplayStep[] = [];
1449
+ const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
1450
+ const pickupOffsetMinutes = pickupLocationSkipped ? 0 : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
1451
+ const actualPickupTime = pickupOffsetMinutes > 0
1452
+ ? new Date(tourStartTime.getTime() + pickupOffsetMinutes * 60 * 1000)
1453
+ : tourStartTime;
1454
+ let pickupTimeDisplay: string;
1455
+ if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
1456
+ const latestPickupTime = new Date(tourStartTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
1457
+ pickupTimeDisplay = `${formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a')}`;
1458
+ } else {
1459
+ pickupTimeDisplay = formatInTimeZone(actualPickupTime, companyTimezone, 'h:mm a');
1460
+ }
1461
+ const pickupPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
1462
+ itineraryItems.push({ stepType: StepType.pickup, time: pickupTimeDisplay, place: pickupPlace });
1463
+ let lastDepartureTime = tourStartTime;
1464
+ itinerary.forEach((itineraryItem, index) => {
1465
+ const destination = destinationMap.get(itineraryItem.destinationName);
1466
+ if (!destination) return;
1467
+ const arrivalTime = new Date(lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000));
1468
+ itineraryItems.push({
1469
+ stepType: StepType.arrive,
1470
+ time: formatInTimeZone(arrivalTime, companyTimezone, 'h:mm a'),
1471
+ place: destination.name
1472
+ });
1473
+ const departureTime = itineraryItem.durationHours
1474
+ ? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
1475
+ : arrivalTime;
1476
+ const hasMoreDestinations = index < itinerary.length - 1;
1477
+ if (itineraryItem.durationHours && hasMoreDestinations) {
1478
+ itineraryItems.push({
1479
+ stepType: StepType.depart,
1480
+ time: formatInTimeZone(departureTime, companyTimezone, 'h:mm a'),
1481
+ place: destination.name
1482
+ });
1483
+ }
1484
+ lastDepartureTime = departureTime;
1485
+ });
1486
+ // Return time: from selected return option (products with return selection) OR from itinerary's last departure (e.g. Emerald Lake shuttle with fixed multi-stop itinerary)
1487
+ const returnDateTime = selectedReturnOption
1488
+ ? parseISO(selectedReturnOption.dateTime)
1489
+ : lastDepartureTime;
1490
+ const lastDestination = itinerary.length > 0 ? destinationMap.get(itinerary[itinerary.length - 1].destinationName) : null;
1491
+ const lastDestName = itinerary.length > 0 && product.destinations
1492
+ ? (destinationMap.get(itinerary[itinerary.length - 1].destinationName)?.name ?? null)
1493
+ : null;
1494
+ const getDropOffMinutes = (loc: { travelMinutesFromDestination?: Record<string, number> }) => {
1495
+ if (lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null) return loc.travelMinutesFromDestination[lastDestName];
1496
+ return 0;
1497
+ };
1498
+ const hasDropOffEstimate = (loc: { travelMinutesFromDestination?: Record<string, number> }) =>
1499
+ Boolean(lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null);
1500
+ const dropOffPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation!.name;
1501
+
1502
+ if (selectedReturnOption) {
1503
+ itineraryItems.push({
1504
+ stepType: StepType.depart,
1505
+ time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
1506
+ place: lastDestination?.name ?? 'the_destination'
1507
+ });
1508
+ } else if (lastDestination) {
1509
+ // No return options: show depart from last stop (e.g. Vermillion Lakes for Emerald Lake shuttle)
1510
+ itineraryItems.push({
1511
+ stepType: StepType.depart,
1512
+ time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
1513
+ place: lastDestination.name
1514
+ });
1515
+ }
1516
+ // Add drop-off step (works for both return-option products and itinerary-only products like Emerald Lake)
1517
+ if (selectedPickupLocation && hasDropOffEstimate(selectedPickupLocation)) {
1518
+ const dropOffOffsetMinutes = getDropOffMinutes(selectedPickupLocation);
1519
+ const dropOffTime = new Date(returnDateTime.getTime() + dropOffOffsetMinutes * 60 * 1000);
1520
+ itineraryItems.push({
1521
+ stepType: StepType.drop_off,
1522
+ time: formatInTimeZone(dropOffTime, companyTimezone, 'h:mm a'),
1523
+ place: dropOffPlace
1524
+ });
1525
+ } else if (pickupLocationSkipped || !selectedPickupLocation) {
1526
+ itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
1527
+ } else if (product.pickupLocations?.length) {
1528
+ const dropOffOffsets = product.pickupLocations.map(getDropOffMinutes);
1529
+ const [minOffset, maxOffset] = [Math.min(...dropOffOffsets), Math.max(...dropOffOffsets)];
1530
+ const earliestDropOff = new Date(returnDateTime.getTime() + minOffset * 60 * 1000);
1531
+ const latestDropOff = new Date(returnDateTime.getTime() + maxOffset * 60 * 1000);
1532
+ itineraryItems.push({
1533
+ stepType: StepType.drop_off,
1534
+ time: minOffset === maxOffset
1535
+ ? formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')
1536
+ : `${formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestDropOff, companyTimezone, 'h:mm a')}`,
1537
+ place: dropOffPlace
1538
+ });
1539
+ } else {
1540
+ itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
1541
+ }
1542
+ return itineraryItems;
1543
+ }, [selectedAvailability, selectedReturnOption, selectedPickupLocation, pickupLocationSkipped, activeOptions, product, getEffectiveItinerary, companyTimezone, maxTimeOffsetMinutes]);
1544
+
1545
+ /**
1546
+ * Itinerary for storage only (API/DB). When pickup is unknown, pickup step time is always a range
1547
+ * (e.g. "9 AM - 10:00 AM") so /manage and confirmation email show it; drop-off stays TBD.
1548
+ * UI during booking is unchanged (uses computeItineraryDisplay).
1549
+ */
1550
+ const computeItineraryDisplayForStorage = useCallback((): ItineraryDisplayStep[] | null => {
1551
+ const base = computeItineraryDisplay();
1552
+ if (!base || base.length === 0) return base;
1553
+ const pickupUnknown = pickupLocationSkipped || (!selectedPickupLocation && product.pickupLocations && product.pickupLocations.length > 0);
1554
+ if (!pickupUnknown) return base;
1555
+ const tourStartTime = selectedAvailability ? parseISO(selectedAvailability.dateTime) : null;
1556
+ if (!tourStartTime) return base;
1557
+ const rangeMinutes = maxTimeOffsetMinutes > 0 ? maxTimeOffsetMinutes : 60;
1558
+ const latestPickupTime = new Date(tourStartTime.getTime() + rangeMinutes * 60 * 1000);
1559
+ const startStr = formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a');
1560
+ const endStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
1561
+ const pickupRangeTime = startStr === endStr ? startStr : `${startStr} - ${endStr}`;
1562
+ return base.map((step, i) =>
1563
+ i === 0 && step.stepType === StepType.pickup && step.place === 'your_pickup_location'
1564
+ ? { ...step, time: pickupRangeTime }
1565
+ : step
1566
+ );
1567
+ }, [computeItineraryDisplay, pickupLocationSkipped, selectedPickupLocation, product.pickupLocations, selectedAvailability, companyTimezone, maxTimeOffsetMinutes]);
1568
+
1569
+ // Product has fees from config (e.g. product-fees.json); API sends these in pricingConfig.fees
1570
+ const hasFees = useMemo(() =>
1571
+ Boolean(pricingConfig?.fees && Object.keys(pricingConfig.fees).length > 0 && Object.values(pricingConfig.fees).some(v => (v?.feePerPerson ?? 0) > 0)),
1572
+ [pricingConfig?.fees]
1573
+ );
1574
+
1575
+ const returnOptionsWithFloor = useMemo(
1576
+ () => selectedAvailability?.returnOptions ?? [],
1577
+ [selectedAvailability?.returnOptions],
1578
+ );
1579
+
1580
+ const selectedReturnOptionWithFloor = selectedReturnOption;
1581
+
1582
+ const returnOptionForOrderSummary = selectedReturnOptionWithFloor;
1583
+ const effectiveSelectedPickupVacancies = useMemo(() => {
1584
+ if (!selectedAvailability) return 0;
1585
+ return Math.max(0, selectedAvailability.vacancies ?? 0);
1586
+ }, [selectedAvailability]);
1587
+ const effectiveSelectedReturnVacancies = useMemo(() => {
1588
+ if (!selectedReturnOption) return null;
1589
+ return Math.max(0, selectedReturnOption.vacancies ?? 0);
1590
+ }, [selectedReturnOption]);
1591
+
1592
+ // Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
1593
+ const pricing = useMemo(
1594
+ () =>
1595
+ buildPricingFromAvailability(
1596
+ selectedAvailability,
1597
+ activeOptions,
1598
+ precomputedPricesByOption,
1599
+ currency,
1600
+ pricingConfig,
1601
+ hasFees,
1602
+ isSimplifiedPricingView,
1603
+ ),
1604
+ [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
1605
+ );
1606
+
1607
+ // Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
1608
+ const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
1609
+ if (!pricingConfig) return null;
1610
+ const selectedOption = activeOptions.find(opt => opt.optionId === selectedAvailability?.productOptionId);
1611
+ const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
1612
+ const isPublicMode = isSimplifiedPricingView;
1613
+ return computePriceBreakdown(
1614
+ pricingConfig,
1615
+ currency,
1616
+ priceCAD,
1617
+ basePriceCAD,
1618
+ hasFees,
1619
+ appliedAdjustments,
1620
+ undefined,
1621
+ baseInDisplayCurrency,
1622
+ isPublicMode
1623
+ );
1624
+ }, [pricingConfig, currency, hasFees, activeOptions, selectedAvailability, isSimplifiedPricingView]);
1625
+
1626
+ // Order summary from mid-layer; UI only displays these values (no calculations).
1627
+ const orderSummary: OrderSummary = useMemo(
1628
+ () =>
1629
+ computeOrderSummary(
1630
+ quantities,
1631
+ pricing,
1632
+ returnOptionForOrderSummary,
1633
+ pricingConfig ?? null,
1634
+ currency,
1635
+ hasFees,
1636
+ cancellationPolicyId
1637
+ ),
1638
+ [quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
1639
+ );
1640
+
1641
+ const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
1642
+ /** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
1643
+ const effectivePartySizeCap = useMemo(() => {
1644
+ if (!selectedAvailability) return 0;
1645
+ const outbound = Math.max(0, selectedAvailability.vacancies ?? 0);
1646
+ if (selectedReturnOption == null) return outbound;
1647
+ const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0);
1648
+ return Math.min(outbound, returnCap);
1649
+ }, [selectedAvailability, selectedReturnOption]);
1650
+
1651
+ const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
1652
+ /** Label for display when policy may be forced by promo (not in pricingConfig list). */
1653
+ const effectiveCancellationPolicyLabel = selectedCancellationPolicy?.label ?? forcedCancellationPolicy?.label ?? (t('booking.flexibleCancellation') || 'Flexible cancellation');
1654
+
1655
+ // When return selection (or refreshed availabilities) caps the party below current ticket counts, trim tickets (non-admin).
1656
+ useEffect(() => {
1657
+ if (isAdmin || !selectedAvailability) return;
1658
+ const cap = effectivePartySizeCap;
1659
+ if (totalQuantity <= cap) return;
1660
+ const over = totalQuantity - cap;
1661
+ setQuantities((prev) => {
1662
+ const next = { ...prev };
1663
+ let remaining = over;
1664
+ const cats = Object.keys(next)
1665
+ .filter((c) => (next[c] ?? 0) > 0)
1666
+ .sort((a, b) => (next[b] ?? 0) - (next[a] ?? 0));
1667
+ for (const cat of cats) {
1668
+ if (remaining <= 0) break;
1669
+ const minQ = 0;
1670
+ const q = next[cat] ?? 0;
1671
+ const reducible = Math.max(0, q - minQ);
1672
+ const dec = Math.min(reducible, remaining);
1673
+ next[cat] = q - dec;
1674
+ remaining -= dec;
1675
+ }
1676
+ return next;
1677
+ });
1678
+ }, [
1679
+ effectivePartySizeCap,
1680
+ totalQuantity,
1681
+ selectedAvailability,
1682
+ isAdmin,
1683
+ ]);
1684
+
1685
+ // Add-on totals (lunch, animals, etc.)
1686
+ const addOnTotal = useMemo(() => {
1687
+ let sum = 0;
1688
+ for (const sel of addOnSelections) {
1689
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1690
+ if (!addOn) continue;
1691
+ const basePrice = addOn.price ?? 0;
1692
+ const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
1693
+ const variantAdjustment = hasVariant
1694
+ ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
1695
+ : 0;
1696
+ sum += (basePrice + variantAdjustment) * (sel.quantity ?? 1);
1697
+ }
1698
+ return sum;
1699
+ }, [addOnSelections, addOns]);
1700
+
1701
+ /** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal. */
1702
+ const checkoutReturnLineAmount = returnPriceAdjustment;
1703
+
1704
+ const effectiveSubtotalBeforeAddOns = subtotal;
1705
+ const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
1706
+
1707
+ /** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
1708
+ const quantitiesSignature = useMemo(
1709
+ () =>
1710
+ Object.entries(quantities)
1711
+ .filter(([, n]) => (n ?? 0) > 0)
1712
+ .sort(([a], [b]) => a.localeCompare(b))
1713
+ .map(([c, n]) => `${c}:${n}`)
1714
+ .join('|'),
1715
+ [quantities]
1716
+ );
1717
+
1718
+ // Fee line items including add-ons (for PriceSummary and CheckoutModal)
1719
+ const feeLineItemsWithAddOns = useMemo(() => {
1720
+ const addOnLines = addOnSelections
1721
+ .map((sel) => {
1722
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
1723
+ if (!addOn) return null;
1724
+ const base = addOn.price ?? 0;
1725
+ const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
1726
+ const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
1727
+ const qty = sel.quantity ?? 1;
1728
+ const amt = (base + adj) * qty;
1729
+ const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
1730
+ const name = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
1731
+ return { name, totalAmount: amt, description: addOn.description ?? undefined };
1732
+ })
1733
+ .filter((x): x is NonNullable<typeof x> => x != null);
1734
+ return [...feeLineItems, ...addOnLines];
1735
+ }, [feeLineItems, addOnSelections, addOns]);
1736
+
1737
+ const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
1738
+ if (!selectedAvailability) return [];
1739
+ const returnLineAmount = checkoutReturnLineAmount;
1740
+ const showReturnLine =
1741
+ Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
1742
+ return [
1743
+ ...ticketLineItems.map((line): PriceSummaryLine => {
1744
+ const rate = pricing.find((r) => r.category === line.category);
1745
+ const breakdown = getPriceBreakdown(
1746
+ line.category,
1747
+ rate?.priceCAD ?? 0,
1748
+ rate?.baseInDisplayCurrency,
1749
+ rate?.appliedAdjustments ?? [],
1750
+ );
1751
+ return {
1752
+ kind: 'ticket',
1753
+ category: line.category,
1754
+ qty: line.qty,
1755
+ itemTotal: line.itemTotal,
1756
+ breakdown,
1757
+ };
1758
+ }),
1759
+ ...(showReturnLine
1760
+ ? [
1761
+ {
1762
+ kind: 'line' as const,
1763
+ label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
1764
+ amount: returnLineAmount,
1765
+ type: 'return',
1766
+ },
1767
+ ]
1768
+ : []),
1769
+ ...(cancellationPolicyFee > 0 && (selectedCancellationPolicy || forcedCancellationPolicy)
1770
+ ? [
1771
+ {
1772
+ kind: 'line' as const,
1773
+ label: effectiveCancellationPolicyLabel,
1774
+ amount: cancellationPolicyFee,
1775
+ type: 'cancellation',
1776
+ },
1777
+ ]
1778
+ : []),
1779
+ ...feeLineItemsWithAddOns.map((fee) => {
1780
+ const isMoraineLakeRoadAccessFee =
1781
+ fee.name.toLowerCase().includes('moraine') &&
1782
+ (fee.name.toLowerCase().includes('access') ||
1783
+ fee.name.toLowerCase().includes('road') ||
1784
+ fee.name.toLowerCase().includes('license'));
1785
+ return {
1786
+ kind: 'line' as const,
1787
+ label: feeLineItems.some((f) => f.name === fee.name)
1788
+ ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
1789
+ : fee.name,
1790
+ amount: fee.totalAmount,
1791
+ type: 'fee',
1792
+ tooltip: isMoraineLakeRoadAccessFee
1793
+ ? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
1794
+ : undefined,
1795
+ };
1796
+ }),
1797
+ ];
1798
+ }, [
1799
+ selectedAvailability,
1800
+ ticketLineItems,
1801
+ pricing,
1802
+ getPriceBreakdown,
1803
+ selectedReturnOption,
1804
+ checkoutReturnLineAmount,
1805
+ totalQuantity,
1806
+ t,
1807
+ cancellationPolicyFee,
1808
+ selectedCancellationPolicy,
1809
+ forcedCancellationPolicy,
1810
+ effectiveCancellationPolicyLabel,
1811
+ feeLineItemsWithAddOns,
1812
+ feeLineItems,
1813
+ ]);
1814
+
1815
+ // Promo discount from backend (order-level only; rates are pre-promo)
1816
+ const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
1817
+ const [isGiftCard, setIsGiftCard] = useState(false);
1818
+ const [isVoucher, setIsVoucher] = useState(false);
1819
+ /** Monotonic id per discount fetch; stale responses are ignored. */
1820
+ const promoDiscountFetchGenerationRef = useRef(0);
1821
+ /** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
1822
+ const promoDiscountParamsRef = useRef({
1823
+ selectedAvailability: null as Availability | null,
1824
+ ticketLineItems: [] as Array<{ category: string; qty: number }>,
1825
+ effectiveSubtotal: 0,
1826
+ appliedPromoCode: null as string | null,
1827
+ });
1828
+
1829
+ const promoDiscountFetchKey = useMemo(() => {
1830
+ if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) return '';
1831
+ const companyId = product.companyId ?? env.COMPANY_ID;
1832
+ if (!companyId) return '';
1833
+ const optionId = selectedAvailability.productOptionId;
1834
+ if (!optionId || !quantitiesSignature) return '';
1835
+ return [
1836
+ appliedPromoCode,
1837
+ companyId,
1838
+ product.productId,
1839
+ optionId,
1840
+ selectedAvailability.dateTime,
1841
+ selectedAvailability.availabilityId ?? '',
1842
+ currency,
1843
+ quantitiesSignature,
1844
+ String(Math.round(effectiveSubtotal * 100)),
1845
+ ].join('::');
1846
+ }, [
1847
+ appliedPromoCode,
1848
+ selectedAvailability?.dateTime,
1849
+ selectedAvailability?.productOptionId,
1850
+ selectedAvailability?.availabilityId,
1851
+ totalQuantity,
1852
+ product.companyId,
1853
+ product.productId,
1854
+ currency,
1855
+ quantitiesSignature,
1856
+ effectiveSubtotal,
1857
+ ]);
1858
+
1859
+ promoDiscountParamsRef.current = {
1860
+ selectedAvailability,
1861
+ ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
1862
+ effectiveSubtotal,
1863
+ appliedPromoCode,
1864
+ };
1865
+
1866
+ useEffect(() => {
1867
+ if (!promoDiscountFetchKey) {
1868
+ setPromoDiscountAmount(0);
1869
+ setIsGiftCard(false);
1870
+ setIsVoucher(false);
1871
+ return;
1872
+ }
1873
+
1874
+ let cancelled = false;
1875
+ const debounceMs = 120;
1876
+ const timer = window.setTimeout(() => {
1877
+ if (cancelled) return;
1878
+ const {
1879
+ selectedAvailability: sel,
1880
+ ticketLineItems: lines,
1881
+ effectiveSubtotal: sub,
1882
+ appliedPromoCode: code,
1883
+ } = promoDiscountParamsRef.current;
1884
+ if (!code || !sel) return;
1885
+ const companyId = product.companyId ?? env.COMPANY_ID;
1886
+ const optionId = sel.productOptionId;
1887
+ if (!companyId || !optionId) return;
1888
+ const items = lines.map((l) => ({ category: l.category, qty: l.qty }));
1889
+ if (items.length === 0) return;
1890
+
1891
+ const generation = ++promoDiscountFetchGenerationRef.current;
1892
+ getPromoDiscount(
1893
+ code,
1894
+ companyId,
1895
+ product.productId,
1896
+ optionId,
1897
+ currency,
1898
+ items,
1899
+ sel.dateTime,
1900
+ sub
1901
+ )
1902
+ .then((res) => {
1903
+ if (cancelled) return;
1904
+ if (generation !== promoDiscountFetchGenerationRef.current) return;
1905
+ setPromoDiscountAmount(res.discount ?? 0);
1906
+ setIsGiftCard(res.isGiftCard ?? false);
1907
+ setIsVoucher(res.isVoucher ?? false);
1908
+ })
1909
+ .catch(() => {
1910
+ if (cancelled) return;
1911
+ if (generation !== promoDiscountFetchGenerationRef.current) return;
1912
+ setPromoDiscountAmount(0);
1913
+ setIsGiftCard(false);
1914
+ setIsVoucher(false);
1915
+ });
1916
+ }, debounceMs);
1917
+
1918
+ return () => {
1919
+ cancelled = true;
1920
+ window.clearTimeout(timer);
1921
+ };
1922
+ }, [promoDiscountFetchKey, product.companyId, product.productId, currency]);
1923
+
1924
+ // Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
1925
+ // Vouchers and gift cards: tax on full subtotal (voucher discount includes tax on free portion; gift card is payment).
1926
+ const effectivePromoDiscountAmount = promoDiscountAmount > 0 ? promoDiscountAmount : 0;
1927
+ const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
1928
+ const effectiveTax =
1929
+ effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
1930
+ ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
1931
+ : taxOnSubtotal;
1932
+ const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
1933
+
1934
+ const changeCheckoutButtonLabel = undefined;
1935
+
1936
+ const deferredInvoiceSubmitLabel = flowUi?.partnerDeferredInvoice
1937
+ ? flowUi.partnerDeferredInvoiceSubmitLabel || 'Continue to book'
1938
+ : undefined;
1939
+
1940
+ const checkoutFormError = error || '';
1941
+
1942
+ useEffect(() => {
1943
+ if (!onPricePreviewChange) return;
1944
+ if (!selectedAvailability || totalQuantity <= 0) {
1945
+ onPricePreviewChange(null);
1946
+ return;
1947
+ }
1948
+ onPricePreviewChange({
1949
+ subtotal: effectiveSubtotal,
1950
+ tax: !isTaxIncludedInPrice ? effectiveTax : 0,
1951
+ total: totalPrice,
1952
+ currency,
1953
+ });
1954
+ }, [
1955
+ onPricePreviewChange,
1956
+ selectedAvailability,
1957
+ totalQuantity,
1958
+ effectiveSubtotal,
1959
+ effectiveTax,
1960
+ currency,
1961
+ isTaxIncludedInPrice,
1962
+ totalPrice,
1963
+ ]);
1964
+
1965
+ // Auto-select product option when date is selected: most popular if set, otherwise first available.
1966
+ useEffect(() => {
1967
+ if (selectedDate && timesForSelectedDate.length > 0 && !selectedAvailability) {
1968
+ const mostPopularOption = activeOptions.find(opt => opt.mostPopular);
1969
+ const candidate = mostPopularOption
1970
+ ? timesForSelectedDate.find(avail => avail.productOptionId === mostPopularOption.optionId && avail.vacancies > 0)
1971
+ : null;
1972
+ const fallback = timesForSelectedDate.find(avail => avail.vacancies > 0);
1973
+ const toSelect = candidate ?? fallback;
1974
+ if (toSelect) {
1975
+ setSelectedAvailability(toSelect);
1976
+ setError('');
1977
+ }
1978
+ }
1979
+ }, [selectedDate, timesForSelectedDateSelectionKey, activeOptionIdsKey, selectedAvailability]);
1980
+
1981
+ // Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
1982
+ // changeByCurrency, feesByCurrency, precomputedPrices, etc.) in one response; we just
1983
+ // re-render with the new currency and pick the right values.
1984
+
1985
+ // Sync selectedAvailability when the availabilities list changes (e.g. after refetch for new date range)
1986
+ useEffect(() => {
1987
+ if (selectedAvailability && availabilities.length > 0) {
1988
+ const updatedAvailability = availabilities.find(
1989
+ avail =>
1990
+ avail.dateTime === selectedAvailability.dateTime &&
1991
+ avail.productOptionId === selectedAvailability.productOptionId
1992
+ );
1993
+ if (updatedAvailability) {
1994
+ setSelectedAvailability(updatedAvailability);
1995
+
1996
+ // Also update selectedReturnOption if it exists
1997
+ if (selectedReturnOption && updatedAvailability.returnOptions) {
1998
+ const updatedReturnOption = updatedAvailability.returnOptions.find(
1999
+ opt => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
2000
+ );
2001
+ if (updatedReturnOption) {
2002
+ setSelectedReturnOption(updatedReturnOption);
2003
+ }
2004
+ }
2005
+ }
2006
+ }
2007
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2008
+ }, [availabilities]); // Update when availabilities change (selectedAvailability/selectedReturnOption intentionally excluded to avoid loops)
2009
+
2010
+ // Auto-select return option when outbound is selected (partner prefill may hint return id/datetime).
2011
+ useEffect(() => {
2012
+ if (selectedAvailability?.returnOptions && selectedAvailability.returnOptions.length > 0 && !selectedReturnOption) {
2013
+ const sorted = [...selectedAvailability.returnOptions].sort(
2014
+ (a, b) => parseISO(a.dateTime).getTime() - parseISO(b.dateTime).getTime()
2015
+ );
2016
+ const initialReturnIdForSelect = initialValues?.returnAvailabilityId?.trim();
2017
+ const initialReturnDtForSelect = initialValues?.returnDateTime?.trim();
2018
+
2019
+ const preferBooked = initialReturnIdForSelect
2020
+ ? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
2021
+ : undefined;
2022
+ const preferByDateTime =
2023
+ initialReturnDtForSelect && !preferBooked
2024
+ ? sorted.find((opt) => {
2025
+ try {
2026
+ return (
2027
+ parseAvailabilityDateTime(opt.dateTime).getTime() ===
2028
+ parseAvailabilityDateTime(initialReturnDtForSelect).getTime()
2029
+ );
2030
+ } catch {
2031
+ return false;
2032
+ }
2033
+ })
2034
+ : undefined;
2035
+ const firstAvailable = sorted.find((opt) => opt.vacancies > 0);
2036
+ const toSelect = preferBooked ?? preferByDateTime ?? firstAvailable;
2037
+ if (toSelect) {
2038
+ setSelectedReturnOption(toSelect);
2039
+ }
2040
+ }
2041
+ }, [
2042
+ selectedAvailability,
2043
+ selectedReturnOption,
2044
+ initialValues?.returnAvailabilityId,
2045
+ initialValues?.returnDateTime,
2046
+ ]);
2047
+
2048
+ // Fetch add-ons when availability (product option) is selected; clear selections when option changes
2049
+ const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
2050
+ const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
2051
+ useEffect(() => {
2052
+ if (!availabilityProductOptionId || !product.companyId) {
2053
+ setAddOns([]);
2054
+ setAddOnSelections([]);
2055
+ return;
2056
+ }
2057
+ const optionChanged = prevAvailabilityProductOptionIdRef.current !== availabilityProductOptionId;
2058
+ if (optionChanged) {
2059
+ setAddOnSelections([]);
2060
+ prevAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
2061
+ }
2062
+ getAddOns(product.companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
2063
+ .then(setAddOns)
2064
+ .catch(() => setAddOns([]));
2065
+ }, [availabilityProductOptionId, product.companyId]);
2066
+
2067
+ // Auto-select cheapest cancellation policy when pricing config loads (not when change flow already has the booked policy)
2068
+ useEffect(() => {
2069
+ if (pricingConfig?.cancellationPolicies && pricingConfig.cancellationPolicies.length > 0 && !cancellationPolicyId) {
2070
+ const sorted = [...pricingConfig.cancellationPolicies].sort((a, b) => {
2071
+ const feeA = a.feeByCurrency[currency] ?? 0;
2072
+ const feeB = b.feeByCurrency[currency] ?? 0;
2073
+ return feeA - feeB;
2074
+ });
2075
+ setCancellationPolicyId(sorted[0].id);
2076
+ }
2077
+ }, [pricingConfig?.cancellationPolicies, cancellationPolicyId, currency]);
2078
+
2079
+ const handleDateSelect = (date: string) => {
2080
+ if (date === selectedDate) return;
2081
+ setSelectedAvailability(null);
2082
+ setSelectedReturnOption(null);
2083
+ };
2084
+
2085
+ handleDateSelectRef.current = handleDateSelect;
2086
+
2087
+ useEffect(() => {
2088
+ if (flowUi?.autoSelectFirstAvailableDate !== true) return;
2089
+ if (isPartialLaunch) return;
2090
+ if (initialValues?.dateTime?.trim()) return;
2091
+ if (selectedDate !== '') return;
2092
+ if (dates.length === 0) return;
2093
+ if (hasAutoSelectedPartnerDateRef.current) return;
2094
+
2095
+ // Match Calendar: a day is "sold out" only when every slot has 0 vacancies.
2096
+ // Always auto-pick the first day with any inventory; only if the whole range is empty and user is admin, fall back to the first day.
2097
+ const firstWithInventory = dates.find((d) =>
2098
+ (availabilitiesByDate[d] ?? []).some((a) => (a.vacancies ?? 0) > 0),
2099
+ );
2100
+ const first = firstWithInventory ?? (isAdmin && dates[0] ? dates[0] : undefined);
2101
+ if (!first) return;
2102
+
2103
+ hasAutoSelectedPartnerDateRef.current = true;
2104
+ setSelectedDate(first);
2105
+ handleDateSelectRef.current(first);
2106
+ if (!suppressCalendarDateScroll) {
2107
+ setTimeout(() => {
2108
+ const container = contentRef?.current;
2109
+ if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
2110
+ container.scrollBy({ top: 400, behavior: 'smooth' });
2111
+ } else if (typeof window !== 'undefined') {
2112
+ window.scrollBy({ top: 400, behavior: 'smooth' });
2113
+ }
2114
+ }, 100);
2115
+ }
2116
+ }, [
2117
+ flowUi?.autoSelectFirstAvailableDate,
2118
+ isPartialLaunch,
2119
+ initialValues?.dateTime,
2120
+ selectedDate,
2121
+ dates,
2122
+ availabilitiesByDate,
2123
+ isAdmin,
2124
+ useWindowScroll,
2125
+ contentRef,
2126
+ suppressCalendarDateScroll,
2127
+ ]);
2128
+
2129
+ useEffect(() => {
2130
+ if (flowUi?.autoSelectFirstHighlightedPickup !== true) return;
2131
+ if (hasAutoSelectedPartnerPickupRef.current) return;
2132
+ if (!highlightedPickupLocationIds?.length) return;
2133
+ if (pickupLocationId) return;
2134
+ if (pickupLocationSkipped) return;
2135
+ if (initialValues?.pickupLocationId?.trim()) return;
2136
+ const locs = product.pickupLocations;
2137
+ if (!locs?.length) return;
2138
+ const match = highlightedPickupLocationIds.find((id) => locs.some((l) => l.id === id));
2139
+ if (!match) return;
2140
+ hasAutoSelectedPartnerPickupRef.current = true;
2141
+ setPickupLocationId(match);
2142
+ setPickupLocationSkipped(false);
2143
+ }, [
2144
+ flowUi?.autoSelectFirstHighlightedPickup,
2145
+ highlightedPickupLocationIds,
2146
+ pickupLocationId,
2147
+ pickupLocationSkipped,
2148
+ initialValues?.pickupLocationId,
2149
+ product.pickupLocations,
2150
+ ]);
2151
+
2152
+ const handleTimeSelect = (availability: Availability) => {
2153
+ setSelectedAvailability(availability);
2154
+ setSelectedReturnOption(null); // Clear return selection when changing start time
2155
+ setError('');
2156
+ };
2157
+
2158
+ const handleQuantityChange = (category: string, delta: number) => {
2159
+ const maxAvailable = isAdmin ? Number.MAX_SAFE_INTEGER : effectivePartySizeCap;
2160
+ const currentQty = quantities[category] || 0;
2161
+ const minQ = 0;
2162
+ const newQty = Math.max(minQ, currentQty + delta);
2163
+ // Admin can overbook; non-admin cannot exceed vacancies
2164
+ if (delta > 0 && !isAdmin && orderSummary.totalQuantity >= maxAvailable) {
2165
+ return;
2166
+ }
2167
+ setQuantities(prev => ({
2168
+ ...prev,
2169
+ [category]: newQty,
2170
+ }));
2171
+ setError('');
2172
+ };
2173
+
2174
+ // Selected availability has a deal applied (promo codes not allowed with deals; vouchers/gift cards still allowed; dynamic pricing alone is ok)
2175
+ const hasOngoingDiscount = useMemo(
2176
+ () =>
2177
+ selectedAvailability?.rates?.some((r) =>
2178
+ (r.appliedAdjustments ?? r.applied_adjustments ?? []).some((a) => (a.type ?? '').toLowerCase() === 'deal')
2179
+ ) ?? false,
2180
+ [selectedAvailability]
2181
+ );
2182
+ const selectedAvailabilityKey = useMemo(
2183
+ () => `${selectedAvailability?.dateTime ?? ''}::${selectedAvailability?.productOptionId ?? ''}`,
2184
+ [selectedAvailability?.dateTime, selectedAvailability?.productOptionId]
2185
+ );
2186
+ // Remember where promo was successfully applied to avoid self-clearing on same selection.
2187
+ const promoAppliedSelectionKeyRef = useRef<string | null>(null);
2188
+ const promoValidateInFlightRef = useRef(false);
2189
+
2190
+ const handleApplyPromo = useCallback(async () => {
2191
+ const code = promoCodeInput.trim().toUpperCase();
2192
+ if (!code) return;
2193
+ // Promo validation/application requires a concrete booking context.
2194
+ if (!selectedAvailability || totalQuantity <= 0) {
2195
+ return;
2196
+ }
2197
+ if (appliedPromoCode === code) return; // Already applied, skip API call
2198
+ const companyId = product.companyId;
2199
+ if (!companyId) return;
2200
+ if (promoValidateInFlightRef.current) return;
2201
+ promoValidateInFlightRef.current = true;
2202
+ setPromoCodeError('');
2203
+ setPromoCodeValidating(true);
2204
+ try {
2205
+ const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
2206
+ if (result.valid) {
2207
+ promoAppliedSelectionKeyRef.current = selectedAvailabilityKey;
2208
+ setAppliedPromoCode(code);
2209
+ fetchedRangesRef.current = [];
2210
+ if (result.forcedCancellationPolicyId && result.forcedCancellationPolicyLabel) {
2211
+ setForcedCancellationPolicy({
2212
+ id: result.forcedCancellationPolicyId,
2213
+ label: result.forcedCancellationPolicyLabel,
2214
+ refundTiers: result.forcedCancellationPolicyRefundTiers,
2215
+ changeWindowHoursBefore: result.forcedChangeWindowHoursBefore ?? undefined,
2216
+ });
2217
+ setCancellationPolicyId(result.forcedCancellationPolicyId);
2218
+ // Scroll to cancellation policy section so user sees the forced policy (after state flush)
2219
+ setTimeout(() => {
2220
+ cancellationPolicyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
2221
+ }, 100);
2222
+ } else {
2223
+ setForcedCancellationPolicy(null);
2224
+ }
2225
+ } else {
2226
+ const errorMsg =
2227
+ result.error === 'Promo codes cannot be stacked with deals'
2228
+ ? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
2229
+ : (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
2230
+ setPromoCodeError(errorMsg);
2231
+ }
2232
+ } catch (err) {
2233
+ setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
2234
+ } finally {
2235
+ promoValidateInFlightRef.current = false;
2236
+ setPromoCodeValidating(false);
2237
+ }
2238
+ }, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t, selectedAvailabilityKey, selectedAvailability, totalQuantity]);
2239
+
2240
+ // When user selects a time with ongoing discount and has a promo applied, re-validate and clear if promo can't be stacked
2241
+ useEffect(() => {
2242
+ if (!appliedPromoCode || !hasOngoingDiscount) return;
2243
+ // Only run this guard when user moved away from the selection where promo was applied.
2244
+ // On the same selection, "deal" adjustments can be promo-driven and would self-clear incorrectly.
2245
+ if (promoAppliedSelectionKeyRef.current === selectedAvailabilityKey) {
2246
+ return;
2247
+ }
2248
+ let cancelled = false;
2249
+ validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, true).then((result) => {
2250
+ if (cancelled) return;
2251
+ if (!result.valid && result.error === 'Promo codes cannot be stacked with deals') {
2252
+ promoAppliedSelectionKeyRef.current = null;
2253
+ setAppliedPromoCode(null);
2254
+ setPromoCodeInput(appliedPromoCode);
2255
+ setPromoCodeError(t('booking.promoCodesCannotStackWithDiscounts') || result.error);
2256
+ setForcedCancellationPolicy(null);
2257
+ setCancellationPolicyId(null);
2258
+ fetchedRangesRef.current = [];
2259
+ }
2260
+ });
2261
+ return () => { cancelled = true; };
2262
+ }, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t, selectedAvailabilityKey]);
2263
+
2264
+ // Ref to avoid effect re-running when handleApplyPromo identity changes (t changes every render)
2265
+ const handleApplyPromoRef = useRef(handleApplyPromo);
2266
+ handleApplyPromoRef.current = handleApplyPromo;
2267
+
2268
+ // Auto-apply promo when user stops typing (mobile-friendly, no Enter key needed).
2269
+ // Do not depend on promoCodeValidating: when it flips false after a failed validate, that would
2270
+ // re-run this effect and schedule another timer → repeated /validate for the same bad code.
2271
+ useEffect(() => {
2272
+ const trimmed = promoCodeInput.trim().toUpperCase();
2273
+ if (!trimmed) return;
2274
+ if (!selectedAvailability || totalQuantity <= 0) return;
2275
+ if (appliedPromoCode === trimmed) return;
2276
+
2277
+ const timer = setTimeout(() => {
2278
+ handleApplyPromoRef.current();
2279
+ }, 600);
2280
+
2281
+ return () => clearTimeout(timer);
2282
+ }, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
2283
+
2284
+ const cancelPendingReservation = useCallback(() => {
2285
+ if (paymentSubmitInFlightRef.current) return;
2286
+ const pending = pendingReservationRef.current;
2287
+ if (pending) {
2288
+ pendingReservationRef.current = null;
2289
+ cancelReservation(pending.reservationReference).catch(() => {});
2290
+ }
2291
+ setShowCheckoutModal(false);
2292
+ setCheckoutModalData(null);
2293
+ setCheckoutClientSecret('');
2294
+ }, []);
2295
+
2296
+ const cancelPendingReservationBestEffort = useCallback(() => {
2297
+ if (paymentSubmitInFlightRef.current) return;
2298
+ const pending = pendingReservationRef.current;
2299
+ if (pending) {
2300
+ pendingReservationRef.current = null;
2301
+ cancelReservationBestEffort(pending.reservationReference);
2302
+ }
2303
+ setShowCheckoutModal(false);
2304
+ setCheckoutModalData(null);
2305
+ setCheckoutClientSecret('');
2306
+ }, []);
2307
+
2308
+ // Parent surfaces (dialog close / embedded back) emit this when user abandons the booking flow.
2309
+ useEffect(() => {
2310
+ const handleAbandon = () => {
2311
+ cancelPendingReservation();
2312
+ };
2313
+ window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
2314
+ return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
2315
+ }, [cancelPendingReservation]);
2316
+
2317
+ useEffect(() => {
2318
+ const handlePageHide = () => {
2319
+ cancelPendingReservationBestEffort();
2320
+ };
2321
+ window.addEventListener('pagehide', handlePageHide);
2322
+ return () => window.removeEventListener('pagehide', handlePageHide);
2323
+ }, [cancelPendingReservationBestEffort]);
2324
+
2325
+ const handleCheckout = async () => {
2326
+ if (!selectedAvailability || totalQuantity === 0) {
2327
+ setError(t('booking.selectTimeAndTickets'));
2328
+ return;
2329
+ }
2330
+ if (!email) {
2331
+ setError(t('booking.enterEmail') || 'Please enter your email address');
2332
+ return;
2333
+ }
2334
+
2335
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
2336
+ setError(t('booking.invalidEmail') || 'Please enter a valid email address');
2337
+ return;
2338
+ }
2339
+
2340
+ if (!firstName?.trim()) {
2341
+ setError(t('booking.enterFirstName') || 'Please enter your first name');
2342
+ return;
2343
+ }
2344
+
2345
+ if (!lastName?.trim()) {
2346
+ setError(t('booking.enterLastName') || 'Please enter your last name');
2347
+ return;
2348
+ }
2349
+
2350
+ // Allow checkout if pickup location is selected OR if user chose "I don't know"
2351
+ if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
2352
+ setError(t('booking.selectPickupLocation'));
2353
+ return;
2354
+ }
2355
+
2356
+ setLoading(true);
2357
+ setError('');
2358
+ paymentSubmitInFlightRef.current = false;
2359
+
2360
+ try {
2361
+ const bookingItems = Object.entries(quantities)
2362
+ .filter(([, count]) => count > 0)
2363
+ .map(([category, count]) => ({ category, count }));
2364
+
2365
+ // Get the productOptionId from the selected availability (we tagged it when fetching)
2366
+ const availabilityProductOptionId = selectedAvailability.productOptionId
2367
+ || activeOptions[0]?.optionId;
2368
+
2369
+ if (!availabilityProductOptionId) {
2370
+ setError('No product option selected');
2371
+ setLoading(false);
2372
+ return;
2373
+ }
2374
+
2375
+ const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
2376
+ clientChannelSource: inferClientBookingSourceFromProductIds(
2377
+ product.productId,
2378
+ availabilityProductOptionId,
2379
+ ),
2380
+ forcePartnerPortalChannel: partnerPortalBooking,
2381
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
2382
+ });
2383
+
2384
+ // Get the hotel name if a pickup location was selected
2385
+ const selectedPickupLocation = pickupLocationId
2386
+ ? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
2387
+ : null;
2388
+ const reservation = await createReservation({
2389
+ productId: availabilityProductOptionId, // GetYourGuide passes productOptionId values in productId field
2390
+ dateTime: selectedAvailability.dateTime,
2391
+ availabilityId: selectedAvailability.availabilityId || undefined,
2392
+ bookingItems,
2393
+ pickupLocationId: pickupLocationId || undefined,
2394
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
2395
+ currency: currency,
2396
+ promoCode: appliedPromoCode || undefined,
2397
+ cancellationPolicyId: cancellationPolicyId || undefined,
2398
+ addOnSelections: addOnSelections.length > 0 ? addOnSelections : undefined,
2399
+ ...(isAdmin ? { allowOverbook: true } : {}),
2400
+ // Pass hotel name when pickup location is selected (for reference)
2401
+ // Don't set travelerHotel when user selects "I don't know" - leave it undefined
2402
+ // This allows us to distinguish between "unknown" (null) and "unmapped hotel name" (not null)
2403
+ travelerHotel: selectedPickupLocation?.name || undefined,
2404
+ // For standard bookings, backend calculates startTime from availability + pickup location offset
2405
+ // When pickup location is skipped, backend will store null for startTime
2406
+ ...(bookingSourceContext.sourceMetadata
2407
+ ? {
2408
+ source: bookingSourceContext.source,
2409
+ sourceMetadata: bookingSourceContext.sourceMetadata,
2410
+ source_metadata: bookingSourceContext.source_metadata,
2411
+ }
2412
+ : {}),
2413
+ });
2414
+ pendingReservationRef.current = { reservationReference: reservation.reservationReference };
2415
+
2416
+ if (!reservation.reservationReference) {
2417
+ throw new Error('Invalid reservation response: missing reservationReference');
2418
+ }
2419
+
2420
+ // Note: Do NOT call onSuccess here for paid bookings — we're about to show the Stripe
2421
+ // CheckoutModal. onSuccess (e.g. closing the parent dialog) should only run when we're
2422
+ // actually done (free booking redirect, admin confirm-without-payment). Calling it here
2423
+ // would close the dialog before the payment modal opens.
2424
+
2425
+ // Update stored booking data with reservation reference
2426
+ try {
2427
+ const storedBooking = sessionStorage.getItem('pendingBooking');
2428
+ if (storedBooking) {
2429
+ const booking = JSON.parse(storedBooking);
2430
+ if (reservation?.reservationReference) {
2431
+ booking.reservationReference = reservation.reservationReference;
2432
+ }
2433
+ booking.totalPrice = reservation?.totalAmount || totalPrice;
2434
+ booking.currency = reservation?.currency || currency || selectedAvailability.currency || 'CAD';
2435
+ sessionStorage.setItem('pendingBooking', JSON.stringify(booking));
2436
+ }
2437
+ } catch (e) {
2438
+ console.warn('Failed to update booking data', e);
2439
+ }
2440
+
2441
+ // Create Payment Intent for embedded checkout modal (order summary with strikethrough + green, Payment Element)
2442
+ const datePart = selectedAvailability.dateTime.split('T')[0];
2443
+ const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
2444
+
2445
+ // Itinerary for storage: when pickup unknown, pickup step is always a range (e.g. "9 AM - 10:00 AM") so /manage and email show it; drop-off stays TBD
2446
+ const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
2447
+
2448
+ // Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
2449
+ // Backend will charge totalAmount and store this as the receipt so /manage matches.
2450
+ const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
2451
+ const amountDueForCheckout = totalPrice;
2452
+ const lines = [
2453
+ ...ticketLineItems.map((line) => ({
2454
+ label: line.category,
2455
+ amount: line.itemTotal,
2456
+ type: 'TICKET' as const,
2457
+ quantity: line.qty,
2458
+ })),
2459
+ ...(checkoutReturnLineAmount !== 0
2460
+ ? [
2461
+ {
2462
+ label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
2463
+ amount: checkoutReturnLineAmount,
2464
+ type: 'RETURN_OPTION' as const,
2465
+ quantity: totalQuantity,
2466
+ },
2467
+ ]
2468
+ : []),
2469
+ ...(cancellationPolicyFee > 0
2470
+ ? [
2471
+ {
2472
+ label: effectiveCancellationPolicyLabel,
2473
+ amount: cancellationPolicyFee,
2474
+ type: 'CANCELLATION_UPGRADE' as const,
2475
+ },
2476
+ ]
2477
+ : []),
2478
+ ...addOnSelections
2479
+ .map((sel) => {
2480
+ const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
2481
+ if (!addOn) return null;
2482
+ const base = addOn.price ?? 0;
2483
+ const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
2484
+ const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
2485
+ const qty = sel.quantity ?? 1;
2486
+ const amt = (base + adj) * qty;
2487
+ const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
2488
+ return {
2489
+ label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name,
2490
+ amount: amt,
2491
+ type: 'FEE' as const,
2492
+ quantity: qty,
2493
+ };
2494
+ })
2495
+ .filter((x): x is NonNullable<typeof x> => x != null),
2496
+ ...feeLineItems.map((fee) => ({
2497
+ label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
2498
+ amount: fee.totalAmount,
2499
+ type: 'FEE' as const,
2500
+ quantity: totalQuantity,
2501
+ })),
2502
+ ...(!isTaxIncludedInPrice && taxForBreakdown > 0
2503
+ ? [
2504
+ {
2505
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
2506
+ amount: taxForBreakdown,
2507
+ type: 'TAX' as const,
2508
+ },
2509
+ ]
2510
+ : []),
2511
+ ...(effectivePromoDiscountAmount > 0
2512
+ ? [
2513
+ {
2514
+ label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (t('booking.discount') || 'Discount'),
2515
+ amount: -effectivePromoDiscountAmount,
2516
+ type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
2517
+ },
2518
+ ]
2519
+ : []),
2520
+ ];
2521
+ const checkoutBreakdown = buildCheckoutBreakdown({
2522
+ lines,
2523
+ totalAmount: amountDueForCheckout,
2524
+ currency,
2525
+ roundingLabel: t('booking.rounding') || 'Rounding',
2526
+ });
2527
+
2528
+ if (flowUi?.partnerDeferredInvoice) {
2529
+ if (!reservation?.reservationReference) {
2530
+ throw new Error('Missing reservation reference for partner booking confirmation');
2531
+ }
2532
+ const confirmedBooking = await confirmPartnerBookingWithoutPayment({
2533
+ reservationReference: reservation.reservationReference,
2534
+ productId: product.productId,
2535
+ optionId: availabilityProductOptionId,
2536
+ date: datePart,
2537
+ time: timePart,
2538
+ customerEmail: email || undefined,
2539
+ customerFirstName: firstName.trim() || undefined,
2540
+ customerLastName: lastName.trim() || undefined,
2541
+ currency: currency,
2542
+ travelerHotel: selectedPickupLocation?.name || undefined,
2543
+ pickupLocationId: pickupLocationId || undefined,
2544
+ itineraryDisplay: itineraryDisplay ?? undefined,
2545
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
2546
+ skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
2547
+ disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
2548
+ checkoutBreakdown,
2549
+ depositAmount: 0,
2550
+ balanceAmount: amountDueForCheckout,
2551
+ totalAmount: amountDueForCheckout,
2552
+ ...bookingSourceContext,
2553
+ });
2554
+ pendingReservationRef.current = null;
2555
+ const ref = formatBookingRefForDisplay(confirmedBooking.bookingReference);
2556
+ const ln = lastName.trim();
2557
+ onSuccess?.({
2558
+ reservationReference: reservation.reservationReference,
2559
+ bookingReference: confirmedBooking.bookingReference,
2560
+ });
2561
+ if (onShowManage) {
2562
+ onShowManage({ ref, lastName: ln });
2563
+ } else {
2564
+ const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
2565
+ window.location.href = `/manage-booking?${params.toString()}`;
2566
+ }
2567
+ setLoading(false);
2568
+ return;
2569
+ }
2570
+
2571
+ const paymentIntent = await createPaymentIntent({
2572
+ productId: product.productId,
2573
+ optionId: availabilityProductOptionId,
2574
+ date: datePart,
2575
+ time: timePart,
2576
+ quantity: totalQuantity,
2577
+ customerEmail: email,
2578
+ customerFirstName: firstName.trim() || undefined,
2579
+ customerLastName: lastName.trim() || undefined,
2580
+ currency: currency,
2581
+ reservationReference: reservation?.reservationReference,
2582
+ travelerHotel: selectedPickupLocation?.name || undefined,
2583
+ pickupLocationId: pickupLocationId || undefined,
2584
+ itineraryDisplay: itineraryDisplay ?? undefined,
2585
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
2586
+ promoCode: appliedPromoCode || undefined,
2587
+ cancellationPolicyId: cancellationPolicyId || undefined,
2588
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
2589
+ checkoutBreakdown,
2590
+ skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
2591
+ disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
2592
+ ...bookingSourceContext,
2593
+ });
2594
+
2595
+ // Free booking (e.g. voucher covers full total): confirm without payment, then redirect to success
2596
+ if ('freeBooking' in paymentIntent && paymentIntent.freeBooking) {
2597
+ if (!reservation?.reservationReference) {
2598
+ throw new Error('Missing reservation reference for free booking confirmation');
2599
+ }
2600
+
2601
+ const freeBookingResult = await confirmFreeBooking({
2602
+ reservationReference: reservation?.reservationReference ?? '',
2603
+ productId: product.productId,
2604
+ optionId: availabilityProductOptionId,
2605
+ date: datePart,
2606
+ time: timePart,
2607
+ customerEmail: email || undefined,
2608
+ customerFirstName: firstName.trim() || undefined,
2609
+ customerLastName: lastName.trim() || undefined,
2610
+ currency: currency,
2611
+ travelerHotel: selectedPickupLocation?.name || undefined,
2612
+ pickupLocationId: pickupLocationId || undefined,
2613
+ itineraryDisplay: itineraryDisplay ?? undefined,
2614
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
2615
+ skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
2616
+ disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
2617
+ ...bookingSourceContext,
2618
+ });
2619
+ pendingReservationRef.current = null;
2620
+
2621
+ // Show manage UI: in provider-dashboard use callback (e.g. dialog); otherwise redirect to /manage
2622
+ const ref = formatBookingRefForDisplay(freeBookingResult.bookingReference);
2623
+ const ln = lastName.trim();
2624
+ onSuccess?.({ reservationReference: reservation?.reservationReference ?? '' });
2625
+ if (onShowManage) {
2626
+ onShowManage({ ref, lastName: ln });
2627
+ } else {
2628
+ const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
2629
+ window.location.href = `/manage-booking?${params.toString()}`;
2630
+ }
2631
+ setLoading(false);
2632
+ return;
2633
+ }
2634
+
2635
+ // Admin: show choice to pay now or confirm without payment (customer owes full balance)
2636
+ if (isAdmin) {
2637
+ if (!reservation?.reservationReference) {
2638
+ throw new Error('Missing reservation reference for admin payment flow');
2639
+ }
2640
+ setError('');
2641
+ setAdminChoiceData({
2642
+ reservationReference: reservation.reservationReference,
2643
+ reservationExpiration: reservation.expiresAt,
2644
+ checkoutBreakdown,
2645
+ totalAmount: amountDueForCheckout,
2646
+ datePart,
2647
+ timePart,
2648
+ availabilityProductOptionId,
2649
+ itineraryDisplay: itineraryDisplay ?? undefined,
2650
+ clientSecret: paymentIntent.clientSecret ?? '',
2651
+ ticketLinesForModal: ticketLineItems.map((line) => {
2652
+ const rate = pricing.find((r) => r.category === line.category);
2653
+ const breakdown = getPriceBreakdown(
2654
+ line.category,
2655
+ rate?.priceCAD ?? 0,
2656
+ rate?.baseInDisplayCurrency,
2657
+ rate?.appliedAdjustments ?? []
2658
+ );
2659
+ return { line, breakdown };
2660
+ }),
2661
+ feeLineItems: feeLineItemsWithAddOns,
2662
+ returnPriceAdjustment: checkoutReturnLineAmount,
2663
+ cancellationPolicyFee,
2664
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
2665
+ subtotal: effectiveSubtotal,
2666
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
2667
+ totalQuantity,
2668
+ isTaxIncludedInPrice,
2669
+ taxRate: pricingConfig?.taxRate ?? 0,
2670
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
2671
+ discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
2672
+ });
2673
+ setShowAdminPaymentChoice(true);
2674
+ setLoading(false);
2675
+ return;
2676
+ }
2677
+
2678
+ const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItems.map((line) => {
2679
+ const rate = pricing.find((r) => r.category === line.category);
2680
+ const breakdown = getPriceBreakdown(
2681
+ line.category,
2682
+ rate?.priceCAD ?? 0,
2683
+ rate?.baseInDisplayCurrency,
2684
+ rate?.appliedAdjustments ?? []
2685
+ );
2686
+ return { line, breakdown };
2687
+ });
2688
+
2689
+ setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
2690
+ setCheckoutModalData({
2691
+ reservationReference: reservation?.reservationReference ?? '',
2692
+ reservationExpiration: reservation?.expiresAt,
2693
+ customerLastName: lastName.trim(),
2694
+ bookingDate: datePart,
2695
+ successUrlOverride: undefined,
2696
+ ticketLines: ticketLinesForModal,
2697
+ feeLineItems: feeLineItemsWithAddOns,
2698
+ returnPriceAdjustment: checkoutReturnLineAmount,
2699
+ cancellationPolicyFee,
2700
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
2701
+ subtotal: effectiveSubtotal,
2702
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
2703
+ total: amountDueForCheckout,
2704
+ totalQuantity,
2705
+ isTaxIncludedInPrice,
2706
+ taxRate: pricingConfig?.taxRate ?? 0,
2707
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
2708
+ discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : undefined,
2709
+ changeTotals: undefined,
2710
+ });
2711
+ setShowCheckoutModal(true);
2712
+ setLoading(false);
2713
+ } catch (err) {
2714
+ if (isInsufficientCapacityReserveError(err)) {
2715
+ try {
2716
+ const merged = await reloadAvailabilitiesAfterReserveConflict();
2717
+ const outbound = findMergedAvailabilityForSelection(merged, selectedAvailability);
2718
+ const outboundVacancies = outbound?.vacancies ?? null;
2719
+ const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
2720
+ setError(
2721
+ describeStandardTourCapacityConflictMessage({
2722
+ partySize: totalQuantity,
2723
+ outboundVacancies,
2724
+ returnVacancies,
2725
+ hasReturnSelection: !!selectedReturnOption,
2726
+ })
2727
+ );
2728
+ reportReserveCapacityConflictClientContext({
2729
+ flow: 'standard_tour',
2730
+ productId: product.productId,
2731
+ selectedDate: selectedDate || null,
2732
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
2733
+ outboundVacanciesAfterRefresh: outboundVacancies,
2734
+ returnVacanciesAfterRefresh: returnVacancies,
2735
+ partySizeOrPassengers: totalQuantity,
2736
+ hasReturnSelection: !!selectedReturnOption,
2737
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
2738
+ reloadAvailabilitiesSucceeded: true,
2739
+ });
2740
+ } catch (reloadErr) {
2741
+ setError(
2742
+ describeStandardTourCapacityConflictMessage({
2743
+ partySize: totalQuantity,
2744
+ outboundVacancies: null,
2745
+ returnVacancies: null,
2746
+ hasReturnSelection: !!selectedReturnOption,
2747
+ })
2748
+ );
2749
+ reportReserveCapacityConflictClientContext({
2750
+ flow: 'standard_tour',
2751
+ productId: product.productId,
2752
+ selectedDate: selectedDate || null,
2753
+ outboundDateTime: selectedAvailability?.dateTime ?? null,
2754
+ outboundVacanciesAfterRefresh: null,
2755
+ returnVacanciesAfterRefresh: null,
2756
+ partySizeOrPassengers: totalQuantity,
2757
+ hasReturnSelection: !!selectedReturnOption,
2758
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
2759
+ reloadAvailabilitiesSucceeded: false,
2760
+ reloadErrorMessage:
2761
+ reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
2762
+ });
2763
+ }
2764
+ setLoading(false);
2765
+ return;
2766
+ }
2767
+ setError(err instanceof Error ? err.message : 'Something went wrong');
2768
+ setLoading(false);
2769
+ }
2770
+ };
2771
+
2772
+ const handleConfirmWithoutPayment = async () => {
2773
+ if (!adminChoiceData) return;
2774
+ setLoading(true);
2775
+ setError('');
2776
+ try {
2777
+ const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
2778
+ clientChannelSource: inferClientBookingSourceFromProductIds(
2779
+ product.productId,
2780
+ adminChoiceData.availabilityProductOptionId,
2781
+ ),
2782
+ forcePartnerPortalChannel: partnerPortalBooking,
2783
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
2784
+ });
2785
+ const result = await confirmBookingWithoutPayment({
2786
+ reservationReference: adminChoiceData.reservationReference,
2787
+ productId: product.productId,
2788
+ optionId: adminChoiceData.availabilityProductOptionId,
2789
+ date: adminChoiceData.datePart,
2790
+ time: adminChoiceData.timePart,
2791
+ customerEmail: email || undefined,
2792
+ customerFirstName: firstName.trim() || undefined,
2793
+ customerLastName: lastName.trim() || undefined,
2794
+ currency: currency,
2795
+ travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
2796
+ pickupLocationId: pickupLocationId || undefined,
2797
+ itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
2798
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
2799
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
2800
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
2801
+ checkoutBreakdown: adminChoiceData.checkoutBreakdown,
2802
+ depositAmount: 0,
2803
+ balanceAmount: adminChoiceData.totalAmount,
2804
+ totalAmount: adminChoiceData.totalAmount,
2805
+ ...bookingSourceContext,
2806
+ });
2807
+ pendingReservationRef.current = null;
2808
+ const ref = formatBookingRefForDisplay(result.bookingReference);
2809
+ const ln = lastName.trim();
2810
+ setShowAdminPaymentChoice(false);
2811
+ setAdminChoiceData(null);
2812
+ onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
2813
+ if (onShowManage) {
2814
+ onShowManage({ ref, lastName: ln });
2815
+ } else {
2816
+ const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
2817
+ window.location.href = `/manage-booking?${params.toString()}`;
2818
+ }
2819
+ } catch (err) {
2820
+ setError(err instanceof Error ? err.message : 'Failed to confirm booking');
2821
+ } finally {
2822
+ setLoading(false);
2823
+ }
2824
+ };
2825
+
2826
+ const handlePayNow = () => {
2827
+ if (!adminChoiceData) return;
2828
+ setShowAdminPaymentChoice(false);
2829
+ setCheckoutClientSecret(adminChoiceData.clientSecret);
2830
+ setCheckoutModalData({
2831
+ reservationReference: adminChoiceData.reservationReference,
2832
+ reservationExpiration: adminChoiceData.reservationExpiration,
2833
+ customerLastName: lastName.trim(),
2834
+ ticketLines: adminChoiceData.ticketLinesForModal,
2835
+ feeLineItems: adminChoiceData.feeLineItems,
2836
+ returnPriceAdjustment: adminChoiceData.returnPriceAdjustment,
2837
+ cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
2838
+ cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
2839
+ subtotal: adminChoiceData.subtotal,
2840
+ tax: adminChoiceData.tax,
2841
+ total: adminChoiceData.totalAmount,
2842
+ totalQuantity: adminChoiceData.totalQuantity,
2843
+ isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
2844
+ taxRate: adminChoiceData.taxRate,
2845
+ promoDiscountAmount: adminChoiceData.promoDiscountAmount,
2846
+ discountLabel: adminChoiceData.discountLabel,
2847
+ });
2848
+ setShowCheckoutModal(true);
2849
+ setAdminChoiceData(null);
2850
+ };
2851
+
2852
+ if (activeOptions.length === 0) {
2853
+ return (
2854
+ <div className="flex items-center justify-center py-16">
2855
+ <div className="text-red-600">{t('booking.noActiveOption') || 'No active product options available'}</div>
2856
+ </div>
2857
+ );
2858
+ }
2859
+
2860
+ return (
2861
+ <div className="booking-flow-root space-y-8">
2862
+ {/* Admin: choose to pay now or confirm without payment (full balance owed) */}
2863
+ <AdminPaymentChoiceModal
2864
+ open={!!(showAdminPaymentChoice && adminChoiceData)}
2865
+ totalAmount={adminChoiceData?.totalAmount ?? 0}
2866
+ currency={currency}
2867
+ loading={loading}
2868
+ error={error}
2869
+ onPayNow={handlePayNow}
2870
+ onConfirmWithoutPayment={handleConfirmWithoutPayment}
2871
+ onCancel={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
2872
+ />
2873
+ {checkoutModalData && (
2874
+ <CheckoutModal
2875
+ open={showCheckoutModal}
2876
+ onClose={cancelPendingReservation}
2877
+ onPaymentSubmitStart={() => {
2878
+ paymentSubmitInFlightRef.current = true;
2879
+ }}
2880
+ onPaymentSubmitError={() => {
2881
+ paymentSubmitInFlightRef.current = false;
2882
+ }}
2883
+ clientSecret={checkoutClientSecret}
2884
+ reservationReference={checkoutModalData.reservationReference}
2885
+ reservationExpiration={checkoutModalData.reservationExpiration}
2886
+ customerLastName={checkoutModalData.customerLastName}
2887
+ successUrlOverride={
2888
+ checkoutModalData.successUrlOverride ??
2889
+ (getSuccessUrl
2890
+ ? getSuccessUrl({
2891
+ reservationRef: checkoutModalData.reservationReference,
2892
+ lastName: checkoutModalData.customerLastName ?? '',
2893
+ focusDate: checkoutModalData.bookingDate,
2894
+ })
2895
+ : undefined)
2896
+ }
2897
+ ticketLines={checkoutModalData.ticketLines}
2898
+ feeLineItems={checkoutModalData.feeLineItems}
2899
+ returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
2900
+ cancellationPolicyFee={checkoutModalData.cancellationPolicyFee}
2901
+ cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
2902
+ subtotal={checkoutModalData.subtotal}
2903
+ tax={checkoutModalData.tax}
2904
+ total={checkoutModalData.total}
2905
+ promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
2906
+ discountLabel={checkoutModalData.discountLabel}
2907
+ totalQuantity={checkoutModalData.totalQuantity}
2908
+ isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
2909
+ taxRate={checkoutModalData.taxRate}
2910
+ changeTotals={checkoutModalData.changeTotals}
2911
+ currency={currency}
2912
+ locale={locale}
2913
+ t={t}
2914
+ />
2915
+ )}
2916
+ {/* Image/video collage - vertical video on left, image grid on right */}
2917
+ {productId && flowUi?.showCollage !== false && (() => {
2918
+ const config = catalog.getProductByIdOrSlug(productId) as {
2919
+ display?: {
2920
+ collageImageIds?: string[];
2921
+ imageIds?: string[];
2922
+ };
2923
+ } | null;
2924
+ const displayProducts = catalog.getProducts(defaultStrings) as Record<
2925
+ string,
2926
+ { id: string; videoUrl?: import('../../constants/products').VideoSources }
2927
+ >;
2928
+ const displayProduct = Object.values(displayProducts).find((p) => p.id === productId);
2929
+ const collageImageIds = config?.display?.collageImageIds ?? config?.display?.imageIds ?? [];
2930
+ const hasVideo = !!displayProduct?.videoUrl;
2931
+ const hasImages = collageImageIds.length > 0;
2932
+ if (!hasVideo && !hasImages) return null;
2933
+ return (
2934
+ <div className="booking-collage-wrapper">
2935
+ <BookingFlowCollage
2936
+ video={displayProduct?.videoUrl}
2937
+ videoPosterImageId={config?.display?.imageIds?.[0]}
2938
+ imageIds={hasImages ? collageImageIds : [config?.display?.imageIds?.[0]].filter(Boolean) as string[]}
2939
+ altPrefix={product.name}
2940
+ />
2941
+ </div>
2942
+ );
2943
+ })()}
2944
+
2945
+ {flowUi?.showTourDescription !== false && (
2946
+ <TourDescription productSlug={productId} locale={locale} defaultExpanded={isPartialLaunch} />
2947
+ )}
2948
+
2949
+ {isPartialLaunch ? null : (
2950
+ <div className="booking-calendar-section">
2951
+ {loadingAvailabilities && availabilities.length === 0 ? (
2952
+ <div className="flex flex-col items-center justify-center py-12 gap-4">
2953
+ <div className="booking-loading-spinner" aria-hidden />
2954
+ <div className="text-stone-600">{t('booking.loadingTimes')}</div>
2955
+ </div>
2956
+ ) : availabilities.length === 0 ? (
2957
+ <div className="text-center py-8 text-stone-500">
2958
+ {t('booking.noAvailability')}
2959
+ </div>
2960
+ ) : (
2961
+ <>
2962
+ {/* Date Selection */}
2963
+ <div>
2964
+ <div className="relative">
2965
+ {loadingAvailabilities && (
2966
+ <div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
2967
+ <div className="flex flex-col items-center gap-3">
2968
+ <div className="booking-loading-spinner" aria-hidden />
2969
+ <div className="text-stone-600">{t('booking.loadingTimes')}</div>
2970
+ </div>
2971
+ </div>
2972
+ )}
2973
+ <Calendar
2974
+ availabilitiesByDate={availabilitiesByDate}
2975
+ selectedDate={selectedDate}
2976
+ syncVisibleWeekToSelectedDate={false}
2977
+ selectableSoldOutDate={null}
2978
+ isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
2979
+ onDateSelect={(date) => {
2980
+ setSelectedDate(date);
2981
+ handleDateSelect(date);
2982
+ if (suppressCalendarDateScroll) return;
2983
+ // Scroll so calendar is almost at top and user sees the rest of the booking flow.
2984
+ // Dialog: scroll inside contentRef. Full-page: fall back to window scroll.
2985
+ setTimeout(() => {
2986
+ const container = contentRef?.current;
2987
+ if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
2988
+ container.scrollBy({ top: 400, behavior: 'smooth' });
2989
+ } else if (typeof window !== 'undefined') {
2990
+ window.scrollBy({ top: 400, behavior: 'smooth' });
2991
+ }
2992
+ }, 100);
2993
+ }}
2994
+ timezone={companyTimezone}
2995
+ earliestDate={earliestAvailabilityDate}
2996
+ onVisibleRangeChange={handleVisibleRangeChange}
2997
+ currency={currency}
2998
+ showCapacity={isAdmin}
2999
+ extraDiscountPercent={calendarDiscountPercent}
3000
+ capDiscountBadgesToBookingDate={null}
3001
+ />
3002
+ </div>
3003
+ </div>
3004
+
3005
+ {/* Form sections - equal spacing between each */}
3006
+ <div className="mt-6 space-y-6">
3007
+ {/* Your itinerary box - shown after date selection, before pickup/return/tickets/pickup location */}
3008
+ {selectedDate && !hideItineraryBox && (() => {
3009
+ const hasItineraryAny = activeOptions.some(o => o.itinerary?.length) && (product.destinations?.length ?? 0) > 0;
3010
+ if (!hasItineraryAny) return null;
3011
+ const formattedDate = selectedDate ? format(parseISO(selectedDate), 'MMM d') : '';
3012
+ if (!selectedAvailability) {
3013
+ return <ItineraryPlaceholder formattedDate={formattedDate} t={t} />;
3014
+ }
3015
+ const itineraryItems = computeItineraryDisplay();
3016
+ if (!itineraryItems || itineraryItems.length === 0) return null;
3017
+ const isBookingComplete = Boolean(selectedAvailability &&
3018
+ selectedReturnOption &&
3019
+ (pickupLocationId || pickupLocationSkipped || !product.pickupLocations || product.pickupLocations.length === 0) &&
3020
+ Object.values(quantities).some(qty => qty > 0) &&
3021
+ email.trim() !== '' &&
3022
+ firstName.trim() !== '' &&
3023
+ lastName.trim() !== '');
3024
+ return (
3025
+ <ItineraryBox
3026
+ selectedDate={selectedDate}
3027
+ formattedDate={formattedDate}
3028
+ itineraryItems={itineraryItems}
3029
+ isBookingComplete={isBookingComplete}
3030
+ isItinerarySticky={isItinerarySticky}
3031
+ stickyTopPx={flowUi?.itineraryStickyTopOffsetPx}
3032
+ isMobile={isMobile}
3033
+ useWindowScroll={useWindowScroll}
3034
+ showTooltip={showTooltip}
3035
+ selectedPickupLocation={selectedPickupLocation}
3036
+ pickupLocationSkipped={pickupLocationSkipped}
3037
+ pickupLocationsCount={product.pickupLocations?.length ?? 0}
3038
+ itineraryRef={itineraryRef}
3039
+ t={t}
3040
+ onTooltipToggle={() => setShowTooltip(!showTooltip)}
3041
+ onTooltipShow={setShowTooltip}
3042
+ />
3043
+ );
3044
+ })()}
3045
+
3046
+ {/* Select pickup time */}
3047
+ {selectedDate && (
3048
+ <PickupTimeSelector
3049
+ pickupTimes={pickupTimes}
3050
+ selectedDateTime={selectedAvailability?.dateTime ?? null}
3051
+ selectedTicketCount={totalQuantity}
3052
+ optionsMap={optionsMap}
3053
+ hasAnyMostPopular={hasAnyMostPopular}
3054
+ isAdmin={isAdmin}
3055
+ pickupLocationSkipped={pickupLocationSkipped}
3056
+ t={t}
3057
+ onTimeSelect={handleTimeSelect}
3058
+ />
3059
+ )}
3060
+
3061
+ {/* Select return time */}
3062
+ {selectedAvailability && selectedAvailability.returnOptions && selectedAvailability.returnOptions.length > 0 && (
3063
+ <ReturnTimeSelector
3064
+ returnOptions={returnOptionsWithFloor}
3065
+ selectedReturnOption={selectedReturnOptionWithFloor}
3066
+ selectedTicketCount={totalQuantity}
3067
+ companyTimezone={companyTimezone}
3068
+ currency={currency}
3069
+ locale={locale}
3070
+ isAdmin={isAdmin}
3071
+ t={t}
3072
+ onReturnSelect={(option) => {
3073
+ const raw = selectedAvailability.returnOptions?.find(
3074
+ (opt) => opt.returnAvailabilityId === option.returnAvailabilityId
3075
+ );
3076
+ setSelectedReturnOption(raw ?? option);
3077
+ }}
3078
+ getStaySummary={calculateStaySummary}
3079
+ />
3080
+ )}
3081
+
3082
+ {/* Cancellation policy selection - all options from config, sorted by cheapest first. Also show when forced by promo. */}
3083
+ {selectedAvailability && ((pricingConfig?.cancellationPolicies?.length ?? 0) > 0 || forcedCancellationPolicy) && (() => {
3084
+ const sortedPolicies = [...(pricingConfig?.cancellationPolicies ?? [])].sort((a, b) => {
3085
+ const feeA = a.feeByCurrency[currency] ?? 0;
3086
+ const feeB = b.feeByCurrency[currency] ?? 0;
3087
+ return feeA - feeB;
3088
+ });
3089
+ return (
3090
+ <div ref={cancellationPolicyRef}>
3091
+ <CancellationPolicySelector
3092
+ policies={sortedPolicies}
3093
+ selectedPolicyId={cancellationPolicyId}
3094
+ currency={currency}
3095
+ locale={locale}
3096
+ t={t}
3097
+ onPolicySelect={setCancellationPolicyId}
3098
+ forcedPolicy={forcedCancellationPolicy}
3099
+ />
3100
+ </div>
3101
+ );
3102
+ })()}
3103
+
3104
+ {/* Ticket Selection */}
3105
+ {selectedAvailability && (
3106
+ <TicketSelector
3107
+ pricing={pricing}
3108
+ quantities={quantities}
3109
+ totalQuantity={totalQuantity}
3110
+ selectedVacancies={effectivePartySizeCap}
3111
+ companyTimezone={companyTimezone}
3112
+ pickupDateTime={selectedAvailability.dateTime}
3113
+ pickupVacancies={effectiveSelectedPickupVacancies}
3114
+ returnDateTime={selectedReturnOption?.dateTime ?? null}
3115
+ returnVacancies={effectiveSelectedReturnVacancies}
3116
+ resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
3117
+ currency={currency}
3118
+ locale={locale}
3119
+ isAdmin={isAdmin}
3120
+ isSimplifiedPricingView={isSimplifiedPricingView}
3121
+ t={t}
3122
+ onQuantityChange={handleQuantityChange}
3123
+ minimumQuantities={undefined}
3124
+ ticketUnitFloorByCategory={undefined}
3125
+ />
3126
+ )}
3127
+
3128
+ {/* Add-ons — optional extras for the selected product option */}
3129
+ {selectedAvailability && totalQuantity > 0 && addOns.length > 0 && (
3130
+ <AddOnsSection
3131
+ addOns={addOns}
3132
+ addOnSelections={addOnSelections}
3133
+ currency={currency}
3134
+ locale={locale}
3135
+ onSelectionsChange={updateAddOnSelections}
3136
+ minimumTotalByAddOnId={undefined}
3137
+ />
3138
+ )}
3139
+
3140
+ {/* Total and Checkout — shared PriceSummary component */}
3141
+ {selectedAvailability && (
3142
+ <>
3143
+ <CheckoutForm
3144
+ priceSummaryLines={checkoutPriceSummaryLines}
3145
+ totalPrice={totalPrice}
3146
+ totalSummaryLabel={undefined}
3147
+ subtotal={
3148
+ subtotal !== totalFromSummary || effectivePromoDiscountAmount > 0 || addOnTotal > 0
3149
+ ? effectiveSubtotal
3150
+ : undefined
3151
+ }
3152
+ taxAmount={
3153
+ !isTaxIncludedInPrice &&
3154
+ (effectivePromoDiscountAmount > 0 ? effectiveTax : tax) > 0
3155
+ ? (effectivePromoDiscountAmount > 0 ? effectiveTax : tax)
3156
+ : 0
3157
+ }
3158
+ taxRate={pricingConfig?.taxRate}
3159
+ currency={currency}
3160
+ locale={locale}
3161
+ t={t}
3162
+ extraBetweenTaxAndTotal={
3163
+ <PromoCodeInput
3164
+ promoCodeInput={promoCodeInput}
3165
+ appliedPromoCode={appliedPromoCode}
3166
+ promoCodeError={promoCodeError}
3167
+ promoCodeValidating={promoCodeValidating}
3168
+ promoDiscountAmount={promoDiscountAmount}
3169
+ currency={currency}
3170
+ locale={locale}
3171
+ t={t}
3172
+ onInputChange={(v) => {
3173
+ setPromoCodeInput(v);
3174
+ setPromoCodeError('');
3175
+ }}
3176
+ onApply={handleApplyPromo}
3177
+ onRemove={() => {
3178
+ promoAppliedSelectionKeyRef.current = null;
3179
+ setAppliedPromoCode(null);
3180
+ setPromoCodeInput('');
3181
+ setPromoCodeError('');
3182
+ setForcedCancellationPolicy(null);
3183
+ setCancellationPolicyId(null); // Reset so auto-select effect picks cheapest
3184
+ fetchedRangesRef.current = [];
3185
+ }}
3186
+ />
3187
+ }
3188
+ firstName={firstName}
3189
+ lastName={lastName}
3190
+ email={email}
3191
+ onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
3192
+ onLastNameChange={(v) => { setLastName(v); setError(''); }}
3193
+ onEmailChange={(v) => { setEmail(v); setError(''); }}
3194
+ readOnlyContactFields={false}
3195
+ pickupLocations={
3196
+ selectedDate && product.pickupLocations && product.pickupLocations.length > 0
3197
+ ? product.pickupLocations
3198
+ : undefined
3199
+ }
3200
+ destinations={product.destinations}
3201
+ pickupLocationId={pickupLocationId}
3202
+ pickupLocationSkipped={pickupLocationSkipped}
3203
+ selectedPickupLocation={selectedPickupLocation}
3204
+ highlightedPickupLocationIds={highlightedPickupLocationIds}
3205
+ onLocationSelect={(locationId) => {
3206
+ setPickupLocationId(locationId);
3207
+ setError('');
3208
+ if (locationId === null && pickupLocationSkipped) {
3209
+ setPickupLocationSkipped(false);
3210
+ } else if (locationId !== null) {
3211
+ setPickupLocationSkipped(false);
3212
+ }
3213
+ }}
3214
+ onSkip={() => {
3215
+ setPickupLocationSkipped(true);
3216
+ setPickupLocationId(null);
3217
+ setError('');
3218
+ }}
3219
+ onChangePickup={() => {
3220
+ setPickupLocationId(null);
3221
+ setPickupLocationSkipped(false);
3222
+ }}
3223
+ termsAccepted={termsAccepted}
3224
+ onTermsChange={(checked) => {
3225
+ setTermsAccepted(checked);
3226
+ setTermsAcceptedAt(checked ? new Date().toISOString() : null);
3227
+ }}
3228
+ isAdmin={isAdmin}
3229
+ showCommunicationAdminSection
3230
+ skipConfirmationCommunications={skipConfirmationCommunications}
3231
+ disableAutoCommunications={disableAutoCommunications}
3232
+ onSkipConfirmationChange={setSkipConfirmationCommunications}
3233
+ onDisableCommunicationsChange={setDisableAutoCommunications}
3234
+ error={checkoutFormError}
3235
+ loading={loading}
3236
+ totalQuantity={totalQuantity}
3237
+ onCheckout={handleCheckout}
3238
+ submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
3239
+ hideSubmitButton={showCheckoutModal || showAdminPaymentChoice}
3240
+ submitDisabled={false}
3241
+ attributionSummary={flowUi?.partnerAttributionSummary}
3242
+ attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
3243
+ attributionConfirmed={partnerAttributionConfirmed}
3244
+ onAttributionConfirmedChange={setPartnerAttributionConfirmed}
3245
+ />
3246
+ </>
3247
+ )}
3248
+ </div>
3249
+ </>
3250
+ )}
3251
+ </div>
3252
+ )}
3253
+ </div>
3254
+ );
3255
+ }
3256
+