@ticketboothapp/booking 1.2.122 → 1.2.124

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