@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/package.json +29 -2
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/BookingWidget.tsx +302 -0
  9. package/src/components/ManageBookingView.tsx +437 -0
  10. package/src/components/PhoneInputWithCountry.module.css +131 -0
  11. package/src/components/PhoneInputWithCountry.tsx +44 -0
  12. package/src/components/PickupLocationDialog.module.css +360 -0
  13. package/src/components/PickupLocationDialog.tsx +357 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/booking/AddOnsSection.module.css +10 -0
  17. package/src/components/booking/AddOnsSection.tsx +184 -0
  18. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  19. package/src/components/booking/BookingDialog.module.css +643 -0
  20. package/src/components/booking/BookingDialog.tsx +356 -0
  21. package/src/components/booking/BookingFlow.tsx +4385 -0
  22. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  23. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  24. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  25. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  26. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  27. package/src/components/booking/BookingProductGrid.module.css +359 -0
  28. package/src/components/booking/BookingProductGrid.tsx +497 -0
  29. package/src/components/booking/Calendar.module.css +616 -0
  30. package/src/components/booking/Calendar.tsx +1123 -0
  31. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  32. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  33. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  34. package/src/components/booking/CheckoutForm.module.css +244 -0
  35. package/src/components/booking/CheckoutForm.tsx +364 -0
  36. package/src/components/booking/CheckoutModal.tsx +451 -0
  37. package/src/components/booking/CurrencySwitcher.tsx +81 -0
  38. package/src/components/booking/DapFlowCollage.tsx +88 -0
  39. package/src/components/booking/DapTourDescription.tsx +35 -0
  40. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  41. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  42. package/src/components/booking/ErrorBoundary.tsx +63 -0
  43. package/src/components/booking/InfoTooltip.tsx +108 -0
  44. package/src/components/booking/ItineraryBox.module.css +258 -0
  45. package/src/components/booking/ItineraryBox.tsx +550 -0
  46. package/src/components/booking/ItineraryBuilder.tsx +82 -0
  47. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  48. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  49. package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
  50. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  51. package/src/components/booking/PickupLocationSelector.tsx +1566 -0
  52. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  53. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  54. package/src/components/booking/PriceBreakdown.tsx +154 -0
  55. package/src/components/booking/PriceSummary.tsx +234 -0
  56. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  57. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  58. package/src/components/booking/PromoCodeInput.module.css +166 -0
  59. package/src/components/booking/PromoCodeInput.tsx +99 -0
  60. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  61. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  62. package/src/components/booking/TermsAcceptance.tsx +111 -0
  63. package/src/components/booking/TicketSelector.module.css +164 -0
  64. package/src/components/booking/TicketSelector.tsx +199 -0
  65. package/src/components/booking/TourDescription.module.css +304 -0
  66. package/src/components/booking/TourDescription.tsx +273 -0
  67. package/src/components/booking/booking-flow-ui.ts +38 -0
  68. package/src/components/booking/booking-flow.css +944 -0
  69. package/src/components/button.css +245 -0
  70. package/src/components/button.tsx +152 -0
  71. package/src/components/colorable-svg.tsx +29 -0
  72. package/src/components/image.css +29 -0
  73. package/src/components/image.tsx +113 -0
  74. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  75. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  76. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
  77. package/src/components/product-tag.module.css +30 -0
  78. package/src/components/product-tag.tsx +34 -0
  79. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  80. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  81. package/src/components/terms/TermsContent.tsx +178 -0
  82. package/src/components/value-pill.module.css +59 -0
  83. package/src/components/value-pill.tsx +46 -0
  84. package/src/constants/images.ts +556 -0
  85. package/src/constants/pill-values.ts +210 -0
  86. package/src/constants/products.ts +155 -0
  87. package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
  88. package/src/contexts/BookingAppContext.tsx +134 -0
  89. package/src/contexts/CompanyContext.tsx +70 -0
  90. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  91. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  92. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  93. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  94. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  95. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  96. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  97. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  98. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  99. package/src/data/product-descriptions/private-tour.en.json +80 -0
  100. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  101. package/src/data/products-config.json +101 -0
  102. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  103. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  104. package/src/index.ts +79 -0
  105. package/src/lib/analytics.ts +197 -0
  106. package/src/lib/booking/booking-source.ts +51 -0
  107. package/src/lib/booking/checkout-breakdown.ts +69 -0
  108. package/src/lib/booking/correlation-id.ts +46 -0
  109. package/src/lib/booking/i18n/config.ts +21 -0
  110. package/src/lib/booking/i18n/index.tsx +144 -0
  111. package/src/lib/booking/i18n/messages/en.json +236 -0
  112. package/src/lib/booking/i18n/messages/fr.json +236 -0
  113. package/src/lib/booking/itinerary-display.ts +36 -0
  114. package/src/lib/booking/itinerary-labels.ts +70 -0
  115. package/src/lib/booking/location-calculations.ts +43 -0
  116. package/src/lib/booking/location-utils.ts +165 -0
  117. package/src/lib/booking/map-utils.ts +153 -0
  118. package/src/lib/booking/marker-icons.ts +113 -0
  119. package/src/lib/booking/normalize-booking-product-id.ts +21 -0
  120. package/src/lib/booking/pickup-location-types.ts +25 -0
  121. package/src/lib/booking/places-api.ts +154 -0
  122. package/src/lib/booking/pricing.ts +466 -0
  123. package/src/lib/booking/product-option-id.ts +35 -0
  124. package/src/lib/booking/source-metadata.ts +226 -0
  125. package/src/lib/booking/sunday-week.ts +14 -0
  126. package/src/lib/booking/theme.ts +83 -0
  127. package/src/lib/booking/trace-context.ts +62 -0
  128. package/src/lib/booking/utils.ts +9 -0
  129. package/src/lib/booking-api.ts +1793 -0
  130. package/src/lib/booking-constants.ts +23 -0
  131. package/src/lib/booking-ref.ts +13 -0
  132. package/src/lib/booking-types.ts +36 -0
  133. package/src/lib/currency.ts +81 -0
  134. package/src/lib/dap-descriptions.ts +50 -0
  135. package/src/lib/dap-itinerary-preview.ts +315 -0
  136. package/src/lib/dependent-add-on-api.ts +434 -0
  137. package/src/lib/env.ts +96 -0
  138. package/src/lib/firebase.ts +20 -0
  139. package/src/lib/job-application-api.ts +83 -0
  140. package/src/lib/manage-booking-embed-print.ts +16 -0
  141. package/src/lib/manage-booking-post-checkout.ts +68 -0
  142. package/src/lib/photo-dap-config.ts +228 -0
  143. package/src/lib/photo-packages.ts +75 -0
  144. package/src/lib/pickup/map-utils.ts +56 -0
  145. package/src/lib/pickup/marker-icons.ts +19 -0
  146. package/src/lib/product-descriptions.ts +66 -0
  147. package/src/lib/products-config.ts +73 -0
  148. package/src/providers/booking-dialog-provider.tsx +282 -0
  149. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  150. package/src/radius.css +5 -0
  151. package/src/spacing.css +7 -0
  152. package/src/strings/en.json +1774 -0
  153. package/src/strings/es.json +1573 -0
  154. package/src/strings/fr.json +1573 -0
  155. package/src/strings/index.js +23 -0
  156. package/src/text-style.css +56 -0
  157. package/src/utils/currency-converter.ts +101 -0
  158. package/tsconfig.json +8 -2
@@ -0,0 +1,562 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { AnimatePresence, motion } from 'framer-motion';
5
+ import { getProductByIdOrSlug, buildMinimalProductFromConfig } from '@/lib/products-config';
6
+ import { getProduct, type Product } from '@/lib/booking-api';
7
+ import { ENV } from '@/lib/env';
8
+ import { formatCurrencyAmount, type Currency } from '@/lib/currency';
9
+ import { BookingFlow, type ChangeFlowSelectionPreview } from './BookingFlow';
10
+ import { useBookingSourceMetadataFromLocation } from '@/hooks/useBookingSourceMetadataFromLocation';
11
+ import styles from './BookingDialog.module.css';
12
+ import './booking-flow.css';
13
+ import type { BookingData } from '@/components/BookingDetails';
14
+ import { getItineraryStepLabel } from '@/lib/booking/itinerary-display';
15
+ import { effectiveProductOptionIdForChangeFlow } from '@/lib/booking/product-option-id';
16
+
17
+ interface ChangeBookingDialogProps {
18
+ isOpen: boolean;
19
+ booking: BookingData;
20
+ onClose: () => void;
21
+ onChangeCompleted?: (preview: ChangeFlowSelectionPreview | null) => void;
22
+ }
23
+
24
+ function formatBookingDate(dateTime: string | null | undefined): string {
25
+ if (!dateTime) return '—';
26
+ try {
27
+ const date = new Date(dateTime);
28
+ return date.toLocaleDateString('en-US', {
29
+ weekday: 'short',
30
+ year: 'numeric',
31
+ month: 'short',
32
+ day: 'numeric',
33
+ timeZone: 'America/Denver',
34
+ });
35
+ } catch {
36
+ return dateTime;
37
+ }
38
+ }
39
+
40
+ function formatBookingItems(
41
+ items: Array<{ category: string; count: number }> | null | undefined
42
+ ): string {
43
+ if (!items?.length) return '—';
44
+ const labels: Record<string, string> = {
45
+ ADULT: 'adult',
46
+ CHILD: 'child',
47
+ INFANT: 'infant',
48
+ SENIOR: 'senior',
49
+ STUDENT: 'student',
50
+ };
51
+ const parts = items
52
+ .filter((item) => item.count > 0)
53
+ .map((item) => {
54
+ const label = labels[item.category] || item.category.toLowerCase();
55
+ return `${item.count} ${label}${item.count !== 1 ? 's' : ''}`;
56
+ });
57
+ return parts.length > 0 ? parts.join(', ') : '—';
58
+ }
59
+
60
+ function getOriginalPromoFromReceipt(receipt: BookingData['receipt']): {
61
+ amount: number;
62
+ label: string | null;
63
+ code: string | null;
64
+ } | null {
65
+ const line = receipt.lineItems.find((item) => {
66
+ const type = (item.type || '').toUpperCase();
67
+ const label = (item.label || '').toLowerCase();
68
+ return (
69
+ type.includes('PROMO') ||
70
+ type.includes('VOUCHER') ||
71
+ type.includes('GIFT') ||
72
+ label.includes('promo') ||
73
+ label.includes('voucher') ||
74
+ label.includes('gift') ||
75
+ label.includes('discount')
76
+ );
77
+ });
78
+ if (!line) return null;
79
+ const amount = Math.abs(line.amount || 0);
80
+ if (amount <= 0) return null;
81
+ const explicitCode = receipt.promoCode?.trim() || null;
82
+ const referenceCode = (line.reference || '').trim() || null;
83
+ const lineLabel = (line.label || '').trim();
84
+ const labelAfterPrefix = lineLabel.replace(/^(promo|voucher|gift\s*card|discount)\s*:\s*/i, '').trim();
85
+ const tokenCandidate = (labelAfterPrefix.split(/\s+/)[0] || '').replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9-]+$/g, '');
86
+ const parsedLabelCode = /^[A-Za-z0-9-]{4,}$/.test(tokenCandidate) ? tokenCandidate : null;
87
+ const code = explicitCode || referenceCode || parsedLabelCode;
88
+ return { amount, label: line.label || receipt.promoCode || receipt.promoName || null, code };
89
+ }
90
+
91
+ type ItineraryStepLine = { time: string | null; label: string };
92
+
93
+ type ChangeHighlightVariant = 'current' | 'new';
94
+
95
+ function BookingChangeSummaryColumn({
96
+ kicker,
97
+ tourName,
98
+ dateStr,
99
+ ticketsStr,
100
+ itinerarySteps,
101
+ highlightVariant,
102
+ totalFormatted,
103
+ dateChanged = false,
104
+ ticketsChanged = false,
105
+ itineraryStepChanged,
106
+ }: {
107
+ kicker: string;
108
+ tourName: string;
109
+ dateStr: string;
110
+ ticketsStr: string;
111
+ itinerarySteps: ItineraryStepLine[] | null;
112
+ highlightVariant: ChangeHighlightVariant;
113
+ /** e.g. "C$123.45" */
114
+ totalFormatted: string;
115
+ dateChanged?: boolean;
116
+ ticketsChanged?: boolean;
117
+ itineraryStepChanged?: boolean[];
118
+ }) {
119
+ const dateClass =
120
+ highlightVariant === 'current' && dateChanged
121
+ ? 'line-through text-stone-500'
122
+ : highlightVariant === 'new' && dateChanged
123
+ ? 'font-semibold text-stone-900'
124
+ : '';
125
+ const ticketsClass =
126
+ highlightVariant === 'current' && ticketsChanged
127
+ ? 'line-through text-stone-500'
128
+ : highlightVariant === 'new' && ticketsChanged
129
+ ? 'font-semibold text-stone-900'
130
+ : '';
131
+
132
+ return (
133
+ <div
134
+ className="overflow-hidden rounded-lg border border-stone-200 px-4 py-3 text-sm text-stone-600"
135
+ style={{ background: 'var(--light-orange-background)' }}
136
+ >
137
+ <p className="text-xs font-semibold uppercase tracking-wide text-stone-500">{kicker}</p>
138
+ <p className="mt-1 text-base font-semibold text-stone-900">Tour: {tourName}</p>
139
+ <dl className="mt-3 space-y-2">
140
+ <div className="flex flex-wrap gap-x-2 gap-y-0">
141
+ <dt className="shrink-0 font-medium text-stone-500">Date:</dt>
142
+ <dd className="min-w-0 text-stone-800">
143
+ {dateClass ? <span className={dateClass}>{dateStr}</span> : dateStr}
144
+ </dd>
145
+ </div>
146
+ <div className="flex flex-wrap gap-x-2 gap-y-0">
147
+ <dt className="shrink-0 font-medium text-stone-500">Tickets:</dt>
148
+ <dd className="min-w-0 text-stone-800">
149
+ {ticketsClass ? <span className={ticketsClass}>{ticketsStr}</span> : ticketsStr}
150
+ </dd>
151
+ </div>
152
+ <div>
153
+ <dt className="font-medium text-stone-500">Itinerary:</dt>
154
+ <dd className="mt-1">
155
+ {itinerarySteps && itinerarySteps.length > 0 ? (
156
+ <ul className="list-none pl-0 text-sm leading-snug text-stone-700">
157
+ {itinerarySteps.map((step, i) => (
158
+ <li
159
+ key={i}
160
+ className={
161
+ itineraryStepChanged?.[i]
162
+ ? highlightVariant === 'current'
163
+ ? 'min-w-0 line-through text-stone-500'
164
+ : 'min-w-0 font-semibold text-stone-900'
165
+ : 'min-w-0'
166
+ }
167
+ >
168
+ {step.time ? (
169
+ <>
170
+ <span
171
+ className={
172
+ itineraryStepChanged?.[i]
173
+ ? highlightVariant === 'current'
174
+ ? 'font-medium text-stone-500'
175
+ : 'font-semibold text-stone-900'
176
+ : 'font-medium text-stone-800'
177
+ }
178
+ >
179
+ {step.time}
180
+ </span>
181
+ <span className={itineraryStepChanged?.[i] && highlightVariant === 'new' ? 'text-stone-400' : 'text-stone-500'}>
182
+ {' '}
183
+ ·{' '}
184
+ </span>
185
+ <span className={itineraryStepChanged?.[i] && highlightVariant === 'new' ? 'font-semibold text-stone-900' : undefined}>
186
+ {step.label}
187
+ </span>
188
+ </>
189
+ ) : (
190
+ <span
191
+ className={
192
+ itineraryStepChanged?.[i] && highlightVariant === 'new' ? 'font-semibold text-stone-900' : undefined
193
+ }
194
+ >
195
+ {step.label}
196
+ </span>
197
+ )}
198
+ </li>
199
+ ))}
200
+ </ul>
201
+ ) : (
202
+ <span className="text-sm text-stone-500">—</span>
203
+ )}
204
+ </dd>
205
+ </div>
206
+ </dl>
207
+ <div className="mt-3 flex flex-wrap items-baseline justify-between gap-x-2 border-t border-stone-200/80 pt-3">
208
+ <span className="font-medium text-stone-500">Total:</span>
209
+ <span className="font-semibold tabular-nums text-stone-900">{totalFormatted}</span>
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ function computeItineraryStepChanged(
216
+ stepsA: ItineraryStepLine[] | null,
217
+ stepsB: ItineraryStepLine[] | null,
218
+ ): boolean[] | undefined {
219
+ if (!stepsA) return undefined;
220
+ return stepsA.map((a, i) => {
221
+ const b = stepsB?.[i];
222
+ return !b || a.time !== b.time || a.label !== b.label;
223
+ });
224
+ }
225
+
226
+ function getFocusableElements(container: HTMLElement): HTMLElement[] {
227
+ const selector =
228
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
229
+ return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
230
+ (el) =>
231
+ !el.hasAttribute('disabled') &&
232
+ el.offsetParent !== null &&
233
+ getComputedStyle(el).visibility !== 'hidden'
234
+ );
235
+ }
236
+
237
+ export default function ChangeBookingDialog({
238
+ isOpen,
239
+ booking,
240
+ onClose,
241
+ onChangeCompleted,
242
+ }: ChangeBookingDialogProps) {
243
+ const bookingSourceAttribution = useBookingSourceMetadataFromLocation();
244
+ const [product, setProduct] = useState<Product | null>(null);
245
+ const [error, setError] = useState<string | null>(null);
246
+ const [newBookingPreview, setNewBookingPreview] = useState<ChangeFlowSelectionPreview | null>(null);
247
+ const dialogRef = useRef<HTMLDivElement>(null);
248
+ const contentRef = useRef<HTMLDivElement>(null);
249
+ const overlayRef = useRef<HTMLDivElement>(null);
250
+
251
+ const bookingProductId = booking.productId;
252
+ // Memoize so the load effect does not re-run every render (buildMinimalProductFromConfig returns a new object each call).
253
+ const config = useMemo(() => getProductByIdOrSlug(bookingProductId), [bookingProductId]);
254
+ const productSlug = config?.display?.slug ?? bookingProductId;
255
+ const apiProductId = config?.productId ?? bookingProductId;
256
+ const minimalProduct = useMemo(
257
+ () => (config ? buildMinimalProductFromConfig(config, ENV.COMPANY_ID) : null),
258
+ [config]
259
+ );
260
+
261
+ useEffect(() => {
262
+ if (!isOpen) return;
263
+ let cancelled = false;
264
+ setError(null);
265
+ if (minimalProduct) setProduct(minimalProduct);
266
+ getProduct(apiProductId, ENV.COMPANY_ID)
267
+ .then((p) => {
268
+ if (!cancelled && p) setProduct(p);
269
+ })
270
+ .catch((err) => {
271
+ if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load product');
272
+ });
273
+ return () => {
274
+ cancelled = true;
275
+ };
276
+ }, [isOpen, apiProductId, minimalProduct]);
277
+
278
+ useEffect(() => {
279
+ if (isOpen) {
280
+ document.body.style.overflow = 'hidden';
281
+ document.body.setAttribute('data-booking-dialog-open', '');
282
+ const overlay = overlayRef.current;
283
+ const videos = document.querySelectorAll('video');
284
+ const pausedVideos: HTMLVideoElement[] = [];
285
+ videos.forEach((video) => {
286
+ if (!overlay?.contains(video) && !video.paused) {
287
+ video.pause();
288
+ pausedVideos.push(video);
289
+ }
290
+ });
291
+ return () => {
292
+ document.body.style.overflow = 'unset';
293
+ document.body.removeAttribute('data-booking-dialog-open');
294
+ pausedVideos.forEach((v) => v.play().catch(() => {}));
295
+ };
296
+ }
297
+ document.body.style.overflow = 'unset';
298
+ document.body.removeAttribute('data-booking-dialog-open');
299
+ }, [isOpen]);
300
+
301
+ useEffect(() => {
302
+ if (!isOpen) return;
303
+ const handleKeyDown = (e: KeyboardEvent) => {
304
+ if (e.key === 'Escape') {
305
+ onClose();
306
+ return;
307
+ }
308
+ if (e.key !== 'Tab' || !dialogRef.current) return;
309
+ const focusable = getFocusableElements(dialogRef.current);
310
+ if (focusable.length === 0) return;
311
+ const first = focusable[0];
312
+ const last = focusable[focusable.length - 1];
313
+ const currentIndex = focusable.indexOf(document.activeElement as HTMLElement);
314
+ if (e.shiftKey) {
315
+ if (currentIndex <= 0 || currentIndex === -1) {
316
+ e.preventDefault();
317
+ last.focus();
318
+ }
319
+ } else if (currentIndex === focusable.length - 1 || currentIndex === -1) {
320
+ e.preventDefault();
321
+ first.focus();
322
+ }
323
+ };
324
+ window.addEventListener('keydown', handleKeyDown);
325
+ return () => window.removeEventListener('keydown', handleKeyDown);
326
+ }, [isOpen, onClose]);
327
+
328
+ useEffect(() => {
329
+ if (isOpen && dialogRef.current) dialogRef.current.focus();
330
+ }, [isOpen]);
331
+
332
+ useEffect(() => {
333
+ if (!isOpen) setNewBookingPreview(null);
334
+ }, [isOpen]);
335
+
336
+ const handleChangeFlowSelectionPreview = useCallback((preview: ChangeFlowSelectionPreview | null) => {
337
+ setNewBookingPreview(preview);
338
+ }, []);
339
+
340
+ if (!isOpen) return null;
341
+
342
+ const originalPromo = getOriginalPromoFromReceipt(booking.receipt);
343
+ const lockedPromoCode = booking.receipt.promoCode?.trim() || originalPromo?.code || null;
344
+ const initialValues = {
345
+ bookingReference: booking.bookingReference ?? null,
346
+ dateTime: booking.dateTime ?? null,
347
+ availabilityId: booking.availabilityId ?? null,
348
+ productOptionId: effectiveProductOptionIdForChangeFlow(booking),
349
+ pickupLocationId: booking.pickupLocationId ?? null,
350
+ returnAvailabilityId: booking.returnAvailabilityId ?? null,
351
+ returnDateTime: booking.returnDateTime ?? null,
352
+ travelerHotel: booking.travelerHotel ?? null,
353
+ bookingItems: booking.bookingItems ?? null,
354
+ addOnSelections: booking.addOnSelections ?? null,
355
+ passengerCount: booking.privateShuttleDetails?.requestedPassengerCount ?? booking.privateShuttleDetails?.passengerCount ?? null,
356
+ customer: {
357
+ firstName: booking.customer?.firstName ?? null,
358
+ lastName: booking.customer?.lastName ?? null,
359
+ email: booking.customer?.email ?? null,
360
+ },
361
+ promoCode: lockedPromoCode,
362
+ cancellationPolicyId: booking.cancellationPolicyId ?? null,
363
+ };
364
+ const receiptCurrency = (booking.receipt.currency as Currency) || 'CAD';
365
+ // Use booking + static config only — not `product.name` (minimal → API product swaps labels).
366
+ const tourName =
367
+ booking.productName?.trim() ||
368
+ config?.display?.shortName?.trim() ||
369
+ bookingProductId;
370
+ const ticketsLine =
371
+ booking.productType === 'PRIVATE_SHUTTLE'
372
+ ? `${booking.privateShuttleDetails?.requestedPassengerCount ?? booking.privateShuttleDetails?.passengerCount ?? 0} passengers`
373
+ : formatBookingItems(booking.bookingItems);
374
+
375
+ const currentItinerarySteps: ItineraryStepLine[] | null =
376
+ booking.itineraryDisplay && booking.itineraryDisplay.length > 0
377
+ ? booking.itineraryDisplay.map((step) => ({
378
+ time: step.time?.trim() || null,
379
+ label: getItineraryStepLabel(step),
380
+ }))
381
+ : null;
382
+
383
+ const newDateStr = newBookingPreview?.dateTime
384
+ ? formatBookingDate(newBookingPreview.dateTime)
385
+ : '—';
386
+ const newTicketsStr = newBookingPreview?.ticketsLine ?? '—';
387
+ const newItinerarySteps: ItineraryStepLine[] | null =
388
+ newBookingPreview?.itinerarySteps && newBookingPreview.itinerarySteps.length > 0
389
+ ? newBookingPreview.itinerarySteps
390
+ : null;
391
+
392
+ const showNewBookingColumn = Boolean(newBookingPreview?.hasChangesFromInitial);
393
+ const dateChanged = Boolean(newBookingPreview?.dateChanged);
394
+ const ticketsChanged = Boolean(newBookingPreview?.ticketsChanged);
395
+ const currentTotalFormatted = formatCurrencyAmount(
396
+ booking.receipt.totalAmount,
397
+ receiptCurrency,
398
+ 'en',
399
+ );
400
+ const newTotalFormatted =
401
+ newBookingPreview != null
402
+ ? formatCurrencyAmount(
403
+ newBookingPreview.selectionTotal,
404
+ newBookingPreview.selectionCurrency,
405
+ 'en',
406
+ )
407
+ : '—';
408
+ const itineraryStepChangedCurrent = showNewBookingColumn
409
+ ? computeItineraryStepChanged(currentItinerarySteps, newItinerarySteps)
410
+ : undefined;
411
+ const itineraryStepChangedNew = showNewBookingColumn
412
+ ? computeItineraryStepChanged(newItinerarySteps, currentItinerarySteps)
413
+ : undefined;
414
+
415
+ return (
416
+ <div ref={overlayRef} className={`${styles.overlay} booking-dialog-overlay`} onClick={onClose}>
417
+ <div
418
+ ref={dialogRef}
419
+ className={`booking-flow-root ${styles.dialog}`}
420
+ onClick={(e) => e.stopPropagation()}
421
+ role="dialog"
422
+ aria-modal="true"
423
+ aria-labelledby="booking-dialog-title"
424
+ tabIndex={-1}
425
+ >
426
+ <header className={styles.header}>
427
+ <div className={styles.headerLeft}>
428
+ <span className={styles.headerSpacer} aria-hidden />
429
+ </div>
430
+ <h2 id="booking-dialog-title" className={styles.title}>
431
+ Change Booking
432
+ </h2>
433
+ <div className={styles.headerRight}>
434
+ <button
435
+ type="button"
436
+ className={styles.closeButton}
437
+ onClick={onClose}
438
+ aria-label="Close"
439
+ >
440
+ <svg
441
+ width="24"
442
+ height="24"
443
+ viewBox="0 0 24 24"
444
+ fill="none"
445
+ stroke="currentColor"
446
+ strokeWidth="2"
447
+ strokeLinecap="round"
448
+ strokeLinejoin="round"
449
+ >
450
+ <path d="M18 6L6 18M6 6l12 12" />
451
+ </svg>
452
+ </button>
453
+ </div>
454
+ </header>
455
+
456
+ <div ref={contentRef} className={styles.content}>
457
+ {!product && !error && (
458
+ <div className={`${styles.screen} booking-flow-preflight`}>
459
+ <div className="flex items-center justify-center py-16">
460
+ <div className="text-stone-600">Loading booking flow...</div>
461
+ </div>
462
+ </div>
463
+ )}
464
+ {error && (
465
+ <div className={`${styles.screen} booking-flow-preflight`}>
466
+ <div className="flex flex-col items-center justify-center py-16 gap-4">
467
+ <p className="text-red-600">{error}</p>
468
+ </div>
469
+ </div>
470
+ )}
471
+ {product && (
472
+ <div className={`${styles.screen} booking-flow-preflight`}>
473
+ <div
474
+ className="sticky top-0 z-10 mb-4 flex w-full flex-col gap-4 rounded-2xl py-4 md:flex-row md:items-stretch"
475
+ style={{ background: 'var(--light-orange-background)' }}
476
+ >
477
+ <motion.div
478
+ layout
479
+ transition={{ type: 'spring', stiffness: 380, damping: 34 }}
480
+ className={
481
+ showNewBookingColumn
482
+ ? 'min-w-0 md:flex-1 md:basis-0'
483
+ : 'min-w-0 w-full'
484
+ }
485
+ >
486
+ <BookingChangeSummaryColumn
487
+ highlightVariant="current"
488
+ kicker="Current booking"
489
+ tourName={tourName}
490
+ dateStr={formatBookingDate(booking.dateTime)}
491
+ ticketsStr={ticketsLine}
492
+ itinerarySteps={currentItinerarySteps}
493
+ totalFormatted={currentTotalFormatted}
494
+ dateChanged={dateChanged}
495
+ ticketsChanged={ticketsChanged}
496
+ itineraryStepChanged={itineraryStepChangedCurrent}
497
+ />
498
+ </motion.div>
499
+ <AnimatePresence initial={false} mode="popLayout">
500
+ {showNewBookingColumn && (
501
+ <motion.div
502
+ key="new-booking-summary"
503
+ layout
504
+ initial={{ opacity: 0, x: 16, scale: 0.99 }}
505
+ animate={{ opacity: 1, x: 0, scale: 1 }}
506
+ exit={{ opacity: 0, x: 16, scale: 0.99 }}
507
+ transition={{ type: 'spring', stiffness: 380, damping: 34 }}
508
+ className="min-w-0 md:flex-1 md:basis-0"
509
+ >
510
+ <BookingChangeSummaryColumn
511
+ highlightVariant="new"
512
+ kicker="New booking"
513
+ tourName={tourName}
514
+ dateStr={newDateStr}
515
+ ticketsStr={newTicketsStr}
516
+ itinerarySteps={newItinerarySteps}
517
+ totalFormatted={newTotalFormatted}
518
+ dateChanged={dateChanged}
519
+ ticketsChanged={ticketsChanged}
520
+ itineraryStepChanged={itineraryStepChangedNew}
521
+ />
522
+ </motion.div>
523
+ )}
524
+ </AnimatePresence>
525
+ </div>
526
+ <BookingFlow
527
+ product={product}
528
+ productId={productSlug}
529
+ onBack={onClose}
530
+ currency="CAD"
531
+ contentRef={contentRef}
532
+ bookingSourceAttribution={bookingSourceAttribution}
533
+ onSuccess={() => {
534
+ onChangeCompleted?.(newBookingPreview);
535
+ onClose();
536
+ }}
537
+ mode="change"
538
+ hideItineraryBox
539
+ originalReceipt={{
540
+ subtotal: booking.receipt.subtotalBeforeTax,
541
+ tax: booking.receipt.taxAmount,
542
+ total: booking.receipt.totalAmount,
543
+ currency: receiptCurrency,
544
+ promoAmount: originalPromo?.amount,
545
+ promoLabel: originalPromo?.label,
546
+ lineItems: booking.receipt.lineItems?.map((line) => ({
547
+ type: line.type,
548
+ label: line.label,
549
+ amount: line.amount,
550
+ quantity: line.quantity ?? undefined,
551
+ })),
552
+ }}
553
+ initialValues={initialValues}
554
+ onChangeFlowSelectionPreview={handleChangeFlowSelectionPreview}
555
+ />
556
+ </div>
557
+ )}
558
+ </div>
559
+ </div>
560
+ </div>
561
+ );
562
+ }