@ticketboothapp/booking 1.2.180 → 1.2.181

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingFlow.tsx +12 -7
  3. package/src/components/booking/BookingDialog.tsx +9 -4
  4. package/src/components/booking/BookingProductGrid.module.css +22 -10
  5. package/src/components/booking/BookingProductGrid.tsx +2 -2
  6. package/src/components/booking/ChangeBookingDialog.tsx +3 -1
  7. package/src/components/booking/ChangeBookingFlow.tsx +1 -1
  8. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +3 -0
  9. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +3 -0
  10. package/src/components/booking/NewBookingFlow.tsx +1 -0
  11. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +1 -1
  12. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +9 -2
  13. package/src/components/booking/TicketSelector.module.css +8 -0
  14. package/src/components/booking/TicketSelector.tsx +8 -0
  15. package/src/components/booking/admin-change-flow-state-helpers.ts +35 -0
  16. package/src/components/booking/availability-cache-policy.ts +10 -0
  17. package/src/components/booking/booking-flow-ui.ts +10 -0
  18. package/src/components/booking/use-private-shuttle-availability.ts +5 -1
  19. package/src/components/booking/use-standard-booking-availability.ts +5 -1
  20. package/src/constants/pill-values.ts +0 -8
  21. package/src/constants/products.ts +2 -2
  22. package/src/data/product-descriptions/private-tour.en.json +1 -2
  23. package/src/lib/booking/i18n/messages/en.json +1 -0
  24. package/src/lib/booking/i18n/messages/fr.json +1 -0
  25. package/src/lib/booking-api.ts +156 -27
  26. package/src/lib/env.ts +13 -0
  27. package/src/providers/booking-dialog-provider.tsx +3 -2
  28. package/src/runtime/types.ts +4 -0
  29. package/src/strings/en.json +1 -2
  30. package/src/strings/es.json +1 -2
  31. package/src/strings/fr.json +1 -2
  32. package/test/change-booking-helpers.test.ts +53 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.180",
3
+ "version": "1.2.181",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -18,7 +18,6 @@ import { CURRENCIES, DEFAULT_CURRENCY, type Currency } from './CurrencySwitcher'
18
18
  import { useCompanyTimezone } from '../../contexts/CompanyContext';
19
19
  import { useBookingApp } from '../../contexts/BookingAppContext';
20
20
  import { useBookingHost } from '../../runtime';
21
- import { filterAvailabilitiesAfterCurrentTime } from '../../lib/booking/booking-cutoffs';
22
21
  import type { ChangeBookingFlowProps } from './booking-flow-types';
23
22
  import {
24
23
  normalizeAddOnSelections,
@@ -26,6 +25,7 @@ import {
26
25
  } from './change-booking-flow-helpers';
27
26
  import {
28
27
  buildChangeBookingMinimumQuantities,
28
+ filterAdminChangeCalendarAvailabilities,
29
29
  isAdminManualOverrideEligibleLine,
30
30
  resolveChangeFlowOriginalDate,
31
31
  resolveInitialChangeProductOptionId,
@@ -156,6 +156,10 @@ export function AdminChangeBookingFlow({
156
156
  isAdmin,
157
157
  companyId: env.COMPANY_ID,
158
158
  });
159
+ const changeFlowOriginalDate = useMemo(
160
+ () => resolveChangeFlowOriginalDate(initialValues?.dateTime, companyTimezone),
161
+ [initialValues?.dateTime, companyTimezone],
162
+ );
159
163
  const [adminAvailabilityNowMs, setAdminAvailabilityNowMs] = useState(() => Date.now());
160
164
 
161
165
  useEffect(() => {
@@ -171,8 +175,13 @@ export function AdminChangeBookingFlow({
171
175
  (rows: Availability[]) =>
172
176
  product.productType === 'PRIVATE_SHUTTLE'
173
177
  ? rows
174
- : filterAvailabilitiesAfterCurrentTime(rows, adminAvailabilityNow),
175
- [adminAvailabilityNow, product.productType],
178
+ : filterAdminChangeCalendarAvailabilities({
179
+ availabilities: rows,
180
+ originalDate: changeFlowOriginalDate,
181
+ companyTimezone,
182
+ now: adminAvailabilityNow,
183
+ }),
184
+ [adminAvailabilityNow, changeFlowOriginalDate, companyTimezone, product.productType],
176
185
  );
177
186
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
178
187
  const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
@@ -279,10 +288,6 @@ export function AdminChangeBookingFlow({
279
288
  const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
280
289
  /** Admin only: disable all auto communications for this booking (provider dashboard). */
281
290
  const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
282
- const changeFlowOriginalDate = useMemo(
283
- () => resolveChangeFlowOriginalDate(initialValues?.dateTime, companyTimezone),
284
- [initialValues?.dateTime, companyTimezone],
285
- );
286
291
  const isProviderDashboardChange = Boolean(onChangeBooking);
287
292
  const useAdminFeAuthoritativeQuote = isAdmin && isProviderDashboardChange;
288
293
  /** Any change from an existing booking (public or provider). */
@@ -8,6 +8,7 @@ import { getProduct, type Product } from '../../lib/booking-api';
8
8
  import './booking-flow.css';
9
9
  import BookingProductGrid from './BookingProductGrid';
10
10
  import { BookingFlow } from './BookingFlow';
11
+ import { VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY } from './booking-flow-ui';
11
12
  import { PrivateShuttleBookingFlow } from './PrivateShuttleBookingFlow';
12
13
  import { BookingFlowPreview } from './BookingFlowPreview';
13
14
  import { useIsBookingLaunchLive } from '../../hooks/useIsBookingLaunchLive';
@@ -162,6 +163,7 @@ function BookFlowScreen({
162
163
  onComplete();
163
164
  }}
164
165
  isPartialLaunch={isPartialLaunch}
166
+ flowUi={{ ticketFinePrintByCategory: VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY }}
165
167
  />
166
168
  )}
167
169
  </div>
@@ -194,6 +196,9 @@ export default function BookingDialog() {
194
196
  const isLaunchLive = useIsBookingLaunchLive();
195
197
  const isPartialLaunch = !isLaunchLive;
196
198
  const currentScreen = stack[stack.length - 1];
199
+ const currentScreenType = currentScreen.type;
200
+ const currentBookFlowProductId =
201
+ currentScreenType === 'book-flow' ? currentScreen.productId : null;
197
202
  const [productName, setProductName] = useState<string | null>(null);
198
203
  const dialogRef = useRef<HTMLDivElement>(null);
199
204
 
@@ -209,18 +214,18 @@ export default function BookingDialog() {
209
214
  const contentRef = useRef<HTMLDivElement>(null);
210
215
 
211
216
  useEffect(() => {
212
- if (currentScreen.type === 'product-grid') {
217
+ if (currentScreenType === 'product-grid') {
213
218
  setProductName(null);
214
219
  }
215
- }, [currentScreen.type]);
220
+ }, [currentScreenType]);
216
221
 
217
222
  // Reset scroll when switching to book-flow so we don't appear partially scrolled
218
223
  // (product-grid restores its own scroll when returning from a product)
219
224
  useEffect(() => {
220
- if (currentScreen.type === 'book-flow' && contentRef.current) {
225
+ if (currentScreenType === 'book-flow' && contentRef.current) {
221
226
  contentRef.current.scrollTop = 0;
222
227
  }
223
- }, [currentScreen.type, currentScreen.type === 'book-flow' ? currentScreen.productId : null]);
228
+ }, [currentBookFlowProductId, currentScreenType]);
224
229
 
225
230
  const overlayRef = useRef<HTMLDivElement>(null);
226
231
 
@@ -49,7 +49,7 @@
49
49
 
50
50
  .grid {
51
51
  display: grid;
52
- grid-template-columns: 1fr 1fr;
52
+ grid-template-columns: 1fr;
53
53
  gap: 0.75rem;
54
54
  }
55
55
 
@@ -65,10 +65,11 @@
65
65
  transform: scale(1.05);
66
66
  }
67
67
 
68
+ /* Mobile: full-width landscape card (single column) */
68
69
  .tileImageContainer {
69
70
  position: relative;
70
71
  width: 100%;
71
- height: 280px;
72
+ height: 220px;
72
73
  overflow: hidden;
73
74
  }
74
75
 
@@ -328,31 +329,42 @@
328
329
  display: none;
329
330
  }
330
331
 
331
- .tileImageContainer {
332
- height: 280px;
333
- }
334
-
335
332
  .tileStartTime {
336
- font-size: 2.5rem;
333
+ font-size: 2rem;
337
334
  }
338
335
 
339
336
  .expandedInner {
340
337
  flex-direction: column;
338
+ min-height: 0;
341
339
  }
342
340
 
343
341
  .expandedImage {
344
- flex: 0 0 340px;
345
- min-height: 340px;
342
+ flex: 0 0 260px;
343
+ min-height: 260px;
346
344
  }
347
345
 
348
346
  .expandedVideo {
349
- min-height: 340px;
347
+ min-height: 260px;
350
348
  }
351
349
 
352
350
  .expandedContent {
353
351
  padding: 1rem;
354
352
  }
355
353
 
354
+ /* Keep the CTA above the fold: title → book button → description → pills */
355
+ .expandedTitle {
356
+ order: -2;
357
+ }
358
+
359
+ .expandedActions {
360
+ order: -1;
361
+ margin: 0;
362
+ }
363
+
364
+ .bookButton {
365
+ width: 100%;
366
+ }
367
+
356
368
  .expandedTags {
357
369
  left: 0.75rem;
358
370
  }
@@ -375,7 +375,7 @@ export default function BookingProductGrid({
375
375
  const expandedProduct =
376
376
  expandedIndex >= 0 ? products[expandedIndex] : null;
377
377
 
378
- // Column count for expand-in-place logic (2 mobile, 3 desktop; compact always 2)
378
+ // Column count for expand-in-place logic (1 mobile, 3 desktop; compact always 2)
379
379
  const [cols, setCols] = useState(compact ? 2 : 3);
380
380
  useEffect(() => {
381
381
  if (compact) {
@@ -383,7 +383,7 @@ export default function BookingProductGrid({
383
383
  return;
384
384
  }
385
385
  const mq = window.matchMedia('(min-width: 768px)');
386
- const update = () => setCols(mq.matches ? 3 : 2);
386
+ const update = () => setCols(mq.matches ? 3 : 1);
387
387
  update();
388
388
  mq.addEventListener('change', update);
389
389
  return () => mq.removeEventListener('change', update);
@@ -8,6 +8,7 @@ import { useBookingHost } from '../../runtime';
8
8
  import { formatCurrencyAmount, type Currency } from '../../lib/currency';
9
9
  import { CURRENCIES, DEFAULT_CURRENCY } from './CurrencySwitcher';
10
10
  import { ChangeBookingFlow } from './ChangeBookingFlow';
11
+ import { VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY } from './booking-flow-ui';
11
12
  import type { ChangeFlowSelectionPreview } from './booking-flow-types';
12
13
  import { useBookingSourceMetadataFromLocation } from '../../hooks/useBookingSourceMetadataFromLocation';
13
14
  import styles from './BookingDialog.module.css';
@@ -131,7 +132,7 @@ export default function ChangeBookingDialog({
131
132
  return () => {
132
133
  cancelled = true;
133
134
  };
134
- }, [isOpen, apiProductId, minimalProduct, staticProduct]);
135
+ }, [isOpen, apiProductId, env.COMPANY_ID, minimalProduct, staticProduct]);
135
136
 
136
137
  useEffect(() => {
137
138
  if (isOpen) {
@@ -448,6 +449,7 @@ export default function ChangeBookingDialog({
448
449
  }}
449
450
  initialValues={initialValues}
450
451
  onChangeFlowSelectionPreview={handleChangeFlowSelectionPreview}
452
+ flowUi={{ ticketFinePrintByCategory: VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY }}
451
453
  />
452
454
  </div>
453
455
  )}
@@ -329,7 +329,6 @@ export function ChangeBookingFlow({
329
329
 
330
330
  const {
331
331
  initialAddOnQtyByKey,
332
- initialAddOnMinQtyByKey,
333
332
  initialAddOnMinTotalByAddOnId,
334
333
  updateAddOnSelections,
335
334
  } = useChangeBookingAddOnFloorController({
@@ -953,6 +952,7 @@ export function ChangeBookingFlow({
953
952
  addOnSelections={addOnSelections}
954
953
  onAddOnSelectionsChange={updateAddOnSelections}
955
954
  minimumTotalByAddOnId={isCustomerSelfServeChange ? initialAddOnMinTotalByAddOnId : undefined}
955
+ ticketFinePrintByCategory={flowUi?.ticketFinePrintByCategory}
956
956
  />
957
957
 
958
958
  {/* Total and Checkout — shared PriceSummary component */}
@@ -50,6 +50,7 @@ export interface ChangeBookingSelectionControlsPanelProps {
50
50
  suppressUnitPrices?: boolean;
51
51
  suppressReturnPerPersonPrices?: boolean;
52
52
  suppressAddOnPrices?: boolean;
53
+ ticketFinePrintByCategory?: Record<string, string>;
53
54
  }
54
55
 
55
56
  export function ChangeBookingSelectionControlsPanel({
@@ -90,6 +91,7 @@ export function ChangeBookingSelectionControlsPanel({
90
91
  suppressUnitPrices = false,
91
92
  suppressReturnPerPersonPrices = false,
92
93
  suppressAddOnPrices = false,
94
+ ticketFinePrintByCategory,
93
95
  }: ChangeBookingSelectionControlsPanelProps) {
94
96
  return (
95
97
  <>
@@ -156,6 +158,7 @@ export function ChangeBookingSelectionControlsPanel({
156
158
  onAddOnSelectionsChange={onAddOnSelectionsChange}
157
159
  minimumTotalByAddOnId={minimumTotalByAddOnId}
158
160
  suppressAddOnPrices={suppressAddOnPrices}
161
+ ticketFinePrintByCategory={ticketFinePrintByCategory}
159
162
  />
160
163
  ) : null}
161
164
  </>
@@ -40,6 +40,7 @@ export interface ChangeBookingTicketsAndAddOnsPanelProps {
40
40
  minimumTotalByAddOnId?: Map<string, number>;
41
41
  suppressUnitPrices?: boolean;
42
42
  suppressAddOnPrices?: boolean;
43
+ ticketFinePrintByCategory?: Record<string, string>;
43
44
  }
44
45
 
45
46
  export function ChangeBookingTicketsAndAddOnsPanel({
@@ -69,6 +70,7 @@ export function ChangeBookingTicketsAndAddOnsPanel({
69
70
  minimumTotalByAddOnId,
70
71
  suppressUnitPrices = false,
71
72
  suppressAddOnPrices = false,
73
+ ticketFinePrintByCategory,
72
74
  }: ChangeBookingTicketsAndAddOnsPanelProps) {
73
75
  const incompatibleSelections = incompatibleAddOnSelections(
74
76
  addOnSelections,
@@ -101,6 +103,7 @@ export function ChangeBookingTicketsAndAddOnsPanel({
101
103
  minimumQuantities={minimumQuantities}
102
104
  ticketUnitFloorByCategory={applyReceiptPaidFloors ? ticketUnitFloorByCategory : undefined}
103
105
  suppressUnitPrices={suppressUnitPrices}
106
+ ticketFinePrintByCategory={ticketFinePrintByCategory}
104
107
  />
105
108
 
106
109
  {totalQuantity > 0 && addOns.length > 0 ? (
@@ -952,6 +952,7 @@ export function NewBookingFlow({
952
952
  returnVacancies={effectiveSelectedReturnVacancies}
953
953
  isSimplifiedPricingView={isSimplifiedPricingView}
954
954
  onQuantityChange={handleQuantityChange}
955
+ ticketFinePrintByCategory={flowUi?.ticketFinePrintByCategory}
955
956
  addOns={addOns}
956
957
  addOnSelections={addOnSelections}
957
958
  onAddOnSelectionsChange={updateAddOnSelections}
@@ -153,7 +153,7 @@ export function PrivateShuttleAddOnsSection({
153
153
  Food restrictions
154
154
  </label>
155
155
  <p className="mb-2 text-sm text-stone-500">
156
- Shuttle includes croissants, coffee, tea, hot chocolate, and trail snacks.
156
+ Shuttle includes coffee, tea, hot chocolate, and trail snacks. If you are adding on breakfast or lunch, please let us know if you have any dietary restrictions or allergies.
157
157
  </p>
158
158
  <textarea
159
159
  id="food-restrictions"
@@ -65,6 +65,7 @@ export interface StandardBookingSelectionControlsPanelProps {
65
65
  returnVacancies: number | null;
66
66
  isSimplifiedPricingView: boolean;
67
67
  onQuantityChange: (category: string, delta: number) => void;
68
+ ticketFinePrintByCategory?: Record<string, string>;
68
69
  addOns: AddOn[];
69
70
  addOnSelections: AddOnSelection[];
70
71
  onAddOnSelectionsChange: AddOnSelectionsChangeHandler;
@@ -107,6 +108,7 @@ export function StandardBookingSelectionControlsPanel({
107
108
  returnVacancies,
108
109
  isSimplifiedPricingView,
109
110
  onQuantityChange,
111
+ ticketFinePrintByCategory,
110
112
  addOns,
111
113
  addOnSelections,
112
114
  onAddOnSelectionsChange,
@@ -142,9 +144,13 @@ export function StandardBookingSelectionControlsPanel({
142
144
  ) : null}
143
145
 
144
146
  {selectedBookingOptionsHydrating ? (
145
- <div className="flex items-center justify-center gap-3 rounded-lg border border-stone-200 bg-white p-4 text-stone-600">
147
+ <div
148
+ className="flex items-center justify-center gap-3 py-2 text-stone-600"
149
+ role="status"
150
+ aria-live="polite"
151
+ >
146
152
  <div className="booking-loading-spinner" aria-hidden />
147
- <div>{t('booking.loadingTimes')}</div>
153
+ <div>{t('booking.confirmingAvailability')}</div>
148
154
  </div>
149
155
  ) : null}
150
156
 
@@ -218,6 +224,7 @@ export function StandardBookingSelectionControlsPanel({
218
224
  onQuantityChange={onQuantityChange}
219
225
  minimumQuantities={undefined}
220
226
  ticketUnitFloorByCategory={undefined}
227
+ ticketFinePrintByCategory={ticketFinePrintByCategory}
221
228
  />
222
229
  ) : null}
223
230
 
@@ -49,6 +49,14 @@
49
49
  color: var(--booking-stone-500, #78716c);
50
50
  }
51
51
 
52
+ .finePrint {
53
+ margin-top: 0.25rem;
54
+ font-size: 0.7rem;
55
+ line-height: 1.3;
56
+ color: var(--booking-stone-500, #78716c);
57
+ max-width: 16rem;
58
+ }
59
+
52
60
  .priceStrikethrough {
53
61
  text-decoration: line-through;
54
62
  color: var(--booking-stone-400, #a8a29e);
@@ -48,6 +48,8 @@ interface TicketSelectorProps {
48
48
  ticketUnitFloorByCategory?: Map<string, number>;
49
49
  /** Hide unit/strike prices (customer change flow until server-backed pricing is shown). */
50
50
  suppressUnitPrices?: boolean;
51
+ /** Optional small fine print rendered under a ticket row, keyed by category (e.g. INFANT). */
52
+ ticketFinePrintByCategory?: Record<string, string>;
51
53
  }
52
54
 
53
55
  export function TicketSelector({
@@ -70,6 +72,7 @@ export function TicketSelector({
70
72
  minimumQuantities,
71
73
  ticketUnitFloorByCategory,
72
74
  suppressUnitPrices = false,
75
+ ticketFinePrintByCategory,
73
76
  }: TicketSelectorProps) {
74
77
  /** Party size vs tighter of pickup/return vacancies (parent passes min of both legs). */
75
78
  const isOverbookedTickets = totalQuantity > selectedVacancies;
@@ -166,6 +169,11 @@ export function TicketSelector({
166
169
  formatCurrencyAmount(effectiveUnitPrice, currency, locale as 'en' | 'fr')
167
170
  )}
168
171
  </p>
172
+ {ticketFinePrintByCategory?.[rate.category] && (
173
+ <p className={styles.finePrint}>
174
+ {ticketFinePrintByCategory[rate.category]}
175
+ </p>
176
+ )}
169
177
  </div>
170
178
  <div className={styles.controls}>
171
179
  <button
@@ -1,5 +1,6 @@
1
1
  import { formatInTimeZone } from 'date-fns-tz';
2
2
  import type { Availability, Product } from '../../lib/booking-api';
3
+ import { isAvailabilityAfterCurrentTime } from '../../lib/booking/booking-cutoffs';
3
4
  import {
4
5
  effectiveProductOptionIdForChangeFlow,
5
6
  normalizeProductOptionIdForChangeFlow,
@@ -45,6 +46,40 @@ export function resolveChangeFlowOriginalDate(
45
46
  }
46
47
  }
47
48
 
49
+ /**
50
+ * Admin changes may need to edit the return for a tour that is already in progress.
51
+ * Keep every availability on that booking's service date visible even after its
52
+ * start time passes, while retaining the normal "future starts only" rule for
53
+ * every other date.
54
+ */
55
+ export function filterAdminChangeCalendarAvailabilities({
56
+ availabilities,
57
+ originalDate,
58
+ companyTimezone,
59
+ now = new Date(),
60
+ }: {
61
+ availabilities: Availability[];
62
+ originalDate: string | null;
63
+ companyTimezone: string;
64
+ now?: Date;
65
+ }): Availability[] {
66
+ return availabilities.filter((availability) => {
67
+ if (originalDate) {
68
+ try {
69
+ const availabilityDate = formatInTimeZone(
70
+ parseAvailabilityDateTime(availability.dateTime),
71
+ companyTimezone,
72
+ 'yyyy-MM-dd',
73
+ );
74
+ if (availabilityDate === originalDate) return true;
75
+ } catch {
76
+ /* fall through to the normal time guard */
77
+ }
78
+ }
79
+ return isAvailabilityAfterCurrentTime(availability, now);
80
+ });
81
+ }
82
+
48
83
  export function buildChangeBookingMinimumQuantities({
49
84
  isCustomerSelfServeChange,
50
85
  isAdmin,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A covered cache range is revalidated only after the cache TTL expires.
3
+ * Reserve/checkout perform their own fresh validation before committing inventory.
4
+ */
5
+ export function shouldRevalidateAvailabilityCache(
6
+ cacheCoversRange: boolean,
7
+ isStale: boolean,
8
+ ): boolean {
9
+ return cacheCoversRange && isStale;
10
+ }
@@ -69,8 +69,18 @@ export interface BookingFlowUiOptions {
69
69
  providerDashboardChangePricingUi?: ProviderDashboardChangePricingUi;
70
70
  /** Override the orange section heading above cancellation policy cards (e.g. photo-first shuttle flow). */
71
71
  cancellationPolicySectionLabel?: string;
72
+ /** Small fine print shown under a ticket category row, keyed by category (e.g. { INFANT: "..." }). */
73
+ ticketFinePrintByCategory?: Record<string, string>;
72
74
  }
73
75
 
76
+ /**
77
+ * Via Via default per-category ticket fine print, shown under the ticket row in the
78
+ * ticket selector. Surfaces that pass their own `flowUi` can override or omit it.
79
+ */
80
+ export const VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY: Record<string, string> = {
81
+ INFANT: 'You are required to bring your own safety seat for children under 40lbs (20kg).',
82
+ };
83
+
74
84
  /**
75
85
  * Baseline UX for embedded partner-style surfaces (partner portal Book tab, TicketBooth provider dashboard):
76
86
  * skip collage + tour description, auto-pick the earliest bookable calendar day and first time slot,
@@ -32,6 +32,7 @@ import {
32
32
  privateShuttleAvailabilityDateInZone,
33
33
  privateShuttleStartTimeAllowed,
34
34
  } from './private-shuttle-availability';
35
+ import { shouldRevalidateAvailabilityCache } from './availability-cache-policy';
35
36
  import { getStartOfCurrentDay } from './standard-booking-availability';
36
37
 
37
38
  interface UsePrivateShuttleAvailabilityParams {
@@ -364,7 +365,10 @@ export function usePrivateShuttleAvailability({
364
365
  );
365
366
  const isStale = availabilitiesCache?.isStale(cached) ?? false;
366
367
  if (cacheCoversRange) {
367
- shouldRevalidateCachedRange = true;
368
+ shouldRevalidateCachedRange = shouldRevalidateAvailabilityCache(
369
+ cacheCoversRange,
370
+ isStale,
371
+ );
368
372
  setAvailabilities(cached.availabilities);
369
373
  if (cached.pricingConfig) applyCachedPricingConfig(cached.pricingConfig);
370
374
  if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);
@@ -39,6 +39,7 @@ import {
39
39
  parseAvailabilityDateTime,
40
40
  shouldSyncSelectedAvailability,
41
41
  } from './standard-booking-availability';
42
+ import { shouldRevalidateAvailabilityCache } from './availability-cache-policy';
42
43
 
43
44
  interface UseStandardBookingAvailabilityParams {
44
45
  product: Product;
@@ -462,7 +463,10 @@ export function useStandardBookingAvailability({
462
463
  );
463
464
  const isStale = availabilitiesCache?.isStale(cached) ?? false;
464
465
  if (cacheCoversRange) {
465
- shouldRevalidateCachedRange = true;
466
+ shouldRevalidateCachedRange = shouldRevalidateAvailabilityCache(
467
+ cacheCoversRange,
468
+ isStale,
469
+ );
466
470
  setAvailabilities(cached.availabilities);
467
471
  if (cached.availabilities.length > 0) {
468
472
  hasLoadedAvailabilitiesRef.current = true;
@@ -7,7 +7,6 @@ const doubleCheckIconPath = '/pill-value-icons/double-check-icon.svg';
7
7
  const hikerIconPath = '/pill-value-icons/hiker-icon.svg';
8
8
  const waterIconPath = '/pill-value-icons/water-icon.svg';
9
9
  const lunchIconPath = '/pill-value-icons/lunch-icon.svg';
10
- const croissantIconPath = '/pill-value-icons/croissant-icon.svg';
11
10
  const locationPinIconPath = '/pill-value-icons/location-pin-icon.svg';
12
11
  const addTimeIconPath = '/pill-value-icons/add-time-icon.svg';
13
12
  const coffeeIconPath = '/pill-value-icons/coffee-icon.svg';
@@ -188,13 +187,6 @@ export const createLunchPillValue = (strings = defaultStrings): PillValue => {
188
187
  };
189
188
  }
190
189
 
191
- export const createCroissantPillValue = (strings = defaultStrings): PillValue => {
192
- return {
193
- icon: croissantIconPath,
194
- label: strings.pillValues.croissant
195
- };
196
- }
197
-
198
190
  export const createHotDrinksPillValue = (strings = defaultStrings): PillValue => {
199
191
  return {
200
192
  icon: coffeeIconPath,
@@ -1,5 +1,5 @@
1
1
  import { ImageData, IMAGES } from './images';
2
- import { PillValue, createDeparturePillValue, createDurationPillValue, createSunrisePillValue, createTwoLakesInOnePillValue, createHikePillValue, createCanoePillValue, createMoneyPillValue, createAddTimePillValue, createHotDrinksPillValue, createLunchPillValue, createCroissantPillValue, createemeraldLakeEscapeTourLocationsPillValues, createCozyBlanketsPillValue } from './pill-values';
2
+ import { PillValue, createDeparturePillValue, createDurationPillValue, createSunrisePillValue, createTwoLakesInOnePillValue, createHikePillValue, createCanoePillValue, createMoneyPillValue, createAddTimePillValue, createHotDrinksPillValue, createLunchPillValue, createemeraldLakeEscapeTourLocationsPillValues, createCozyBlanketsPillValue } from './pill-values';
3
3
  export enum ProductTagStyle {
4
4
  MOST_POPULAR = 'most-popular',
5
5
  NEW = 'new',
@@ -149,7 +149,7 @@ export const getProducts = (strings: any): Record<string, Product> => ({
149
149
  description: strings.productThemePages.privateTours.description,
150
150
  path: '/private-shuttle',
151
151
  avgPrice: 1699,
152
- pillValues: [createDeparturePillValue('private-tour', strings), createDurationPillValue('private-tour', strings), createCroissantPillValue(strings), createHotDrinksPillValue(strings), createMoneyPillValue('private-tour', strings)]
152
+ pillValues: [createDeparturePillValue('private-tour', strings), createDurationPillValue('private-tour', strings), createHotDrinksPillValue(strings), createMoneyPillValue('private-tour', strings)]
153
153
  }
154
154
  });
155
155
 
@@ -35,8 +35,7 @@
35
35
  "• Trailsnacks 🍫",
36
36
  "• Water to refills - bring your water bottle 💧",
37
37
  "• Complimentary hot chocolate, coffee & tea ☕️",
38
- "• Fresh croissant from a local french-canadian bakery 🥐",
39
- "• Lunch options available 🍽️",
38
+ "• Breakfast and Lunch options available 🍽️",
40
39
  "• Phone chargers 🔋 ",
41
40
  "• Moraine Lake Access Fee 🏞️",
42
41
  "• Banff National Park Pass 🏞️"
@@ -80,6 +80,7 @@
80
80
  "selectTimeAndTickets": "Please select a time and at least one ticket",
81
81
  "selectPickupLocation": "Please select a pickup location",
82
82
  "loadingTimes": "Loading available times...",
83
+ "confirmingAvailability": "Confirming availability...",
83
84
  "noAvailability": "No availability found for the next 30 days. Please check back later.",
84
85
  "seeFullTourDescription": "See full tour description",
85
86
  "seeFullAddOnDescription": "See full experience details",
@@ -80,6 +80,7 @@
80
80
  "selectTimeAndTickets": "Veuillez sélectionner une heure et au moins un billet",
81
81
  "selectPickupLocation": "Veuillez sélectionner un lieu de prise en charge",
82
82
  "loadingTimes": "Chargement des heures disponibles...",
83
+ "confirmingAvailability": "Confirmation des disponibilités...",
83
84
  "noAvailability": "Aucune disponibilité trouvée pour les 30 prochains jours. Veuillez réessayer plus tard.",
84
85
  "seeFullTourDescription": "Voir la description complète du circuit",
85
86
  "seeFullAddOnDescription": "Voir tous les détails de l'expérience",
@@ -23,15 +23,28 @@ import {
23
23
  type BookingSourceMetadata,
24
24
  } from './booking/source-metadata';
25
25
 
26
- const API_BASE = ENV.API_URL;
26
+ const API_BASE = ENV.API_URL.replace(/\/$/, '');
27
+ const BOOKING_READ_API_BASE = ENV.BOOKING_READ_API_URL.replace(/\/$/, '');
28
+ const BOOKING_GATEWAY_PREFIX = '/api/booking';
27
29
 
28
30
  /** When set (e.g. booking-portal partner session), reserve/checkout use Bearer instead of Basic. */
29
31
  let partnerPortalBookingJwtGetter: () => string | null = () => null;
32
+ let partnerPortalBookingAuthorizationFailureHandler: () => void = () => {};
33
+
34
+ /** Partner sessions retain their Bearer-authenticated read path. Public reads use the gateway. */
35
+ function bookingReadApiBase(): string {
36
+ return partnerPortalBookingJwtGetter() ? API_BASE : BOOKING_READ_API_BASE;
37
+ }
30
38
 
31
39
  export function setPartnerPortalBookingJwtGetter(fn: () => string | null): void {
32
40
  partnerPortalBookingJwtGetter = fn;
33
41
  }
34
42
 
43
+ /** Called when TicketBooth rejects the current partner JWT so the portal can clear stale authority. */
44
+ export function setPartnerPortalBookingAuthorizationFailureHandler(fn: () => void): void {
45
+ partnerPortalBookingAuthorizationFailureHandler = fn;
46
+ }
47
+
35
48
  interface ApiErrorPayload {
36
49
  errorCode?: string;
37
50
  errorMessage?: string;
@@ -42,15 +55,85 @@ function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
42
55
  return typeof value === 'object' && value !== null;
43
56
  }
44
57
 
58
+ function notifyPartnerPortalAuthorizationFailure(): void {
59
+ if (!partnerPortalBookingJwtGetter()) return;
60
+ try {
61
+ partnerPortalBookingAuthorizationFailureHandler();
62
+ } catch {
63
+ // Authentication cleanup must never mask the original API error.
64
+ }
65
+ }
66
+
67
+ function isRejectedAuthenticationPayload(value: unknown): value is ApiErrorPayload {
68
+ if (!isApiErrorPayload(value) || value.errorCode !== 'AUTHORIZATION_FAILURE') return false;
69
+ return value.errorMessage?.trim().toLowerCase() !== 'access denied';
70
+ }
71
+
45
72
  async function parseJsonSafely(res: Response): Promise<unknown> {
46
73
  try {
47
- return await res.json();
74
+ const payload: unknown = await res.json();
75
+ if (isRejectedAuthenticationPayload(payload)) {
76
+ notifyPartnerPortalAuthorizationFailure();
77
+ }
78
+ return payload;
48
79
  } catch {
49
80
  return null;
50
81
  }
51
82
  }
52
83
 
53
84
  type BookingClientErrorClass = 'NETWORK' | 'HTTP' | 'APP_ERROR_200';
85
+ const TELEMETRY_DEDUPE_WINDOW_MS = 30_000;
86
+ const telemetryDedupeFallback = new Map<string, number>();
87
+
88
+ function telemetryDedupeKey(
89
+ eventName: string,
90
+ correlationId: string,
91
+ fields: Record<string, unknown>
92
+ ): string | null {
93
+ if (
94
+ eventName === 'BOOKING_DIALOG_REQUEST_TIMING' ||
95
+ eventName === 'BOOKING_DIALOG_REQUEST_RETRYING' ||
96
+ eventName === 'BOOKING_DIALOG_NETWORK_PROBE_PING'
97
+ ) return null;
98
+ const endpoint = typeof fields.endpoint === 'string' ? fields.endpoint : '';
99
+ if (!endpoint) return null;
100
+ const signature = [
101
+ correlationId,
102
+ eventName,
103
+ endpoint,
104
+ fields.errorClass,
105
+ fields.errorCode,
106
+ fields.httpStatus,
107
+ fields.errorName,
108
+ ].map((value) => String(value ?? '')).join('|');
109
+ let hash = 2166136261;
110
+ for (let index = 0; index < signature.length; index += 1) {
111
+ hash ^= signature.charCodeAt(index);
112
+ hash = Math.imul(hash, 16777619);
113
+ }
114
+ return `tb_booking_telemetry_${(hash >>> 0).toString(16)}`;
115
+ }
116
+
117
+ function shouldEmitBookingTelemetry(
118
+ eventName: string,
119
+ correlationId: string,
120
+ fields: Record<string, unknown>
121
+ ): boolean {
122
+ const key = telemetryDedupeKey(eventName, correlationId, fields);
123
+ if (!key) return true;
124
+ const now = Date.now();
125
+ try {
126
+ const previous = Number(sessionStorage.getItem(key));
127
+ if (Number.isFinite(previous) && now - previous < TELEMETRY_DEDUPE_WINDOW_MS) return false;
128
+ sessionStorage.setItem(key, String(now));
129
+ return true;
130
+ } catch {
131
+ const previous = telemetryDedupeFallback.get(key);
132
+ if (previous != null && now - previous < TELEMETRY_DEDUPE_WINDOW_MS) return false;
133
+ telemetryDedupeFallback.set(key, now);
134
+ return true;
135
+ }
136
+ }
54
137
 
55
138
  /** Thrown by booking-api helpers; includes API error details for UX branching (e.g. capacity conflicts). */
56
139
  export type BookingClientError = Error & {
@@ -88,7 +171,7 @@ function logBookingApiNetworkError(endpoint: string, err: unknown): void {
88
171
  if (typeof window === 'undefined') return;
89
172
  const details = {
90
173
  endpoint,
91
- apiBase: API_BASE,
174
+ apiBase: bookingReadApiBase(),
92
175
  online: window.navigator.onLine,
93
176
  userAgent: window.navigator.userAgent,
94
177
  error: err instanceof Error ? err.message : String(err),
@@ -102,16 +185,18 @@ function reportBookingClientTelemetryEvent(
102
185
  fields: Record<string, unknown>
103
186
  ): void {
104
187
  if (typeof window === 'undefined') return;
105
- const telemetryEndpoint = `${API_BASE}/1/client-telemetry`;
188
+ const telemetryEndpoint = `${BOOKING_READ_API_BASE}/1/client-telemetry`;
106
189
  const correlationId = getOrCreateBookingCorrelationId();
190
+ if (!shouldEmitBookingTelemetry(eventName, correlationId, fields)) return;
107
191
  const traceparent = buildTraceparent();
108
192
  const traceId = traceIdFromTraceparent(traceparent);
109
193
  const event = {
110
194
  event: eventName,
195
+ clientBuildId: ENV.BOOKING_CLIENT_BUILD_ID,
111
196
  correlationId,
112
197
  traceparent,
113
198
  ...(traceId ? { traceId } : {}),
114
- apiBase: API_BASE,
199
+ apiBase: BOOKING_READ_API_BASE,
115
200
  pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
116
201
  userAgent: window.navigator.userAgent,
117
202
  online: window.navigator.onLine,
@@ -153,6 +238,11 @@ function reportClientFetchError(payload: {
153
238
  });
154
239
  }
155
240
 
241
+ function gatewayResponseMetadata(response: Response): Record<string, string> | undefined {
242
+ const requestId = response.headers.get('X-Booking-Gateway-Request-Id');
243
+ return requestId ? { gatewayRequestId: requestId } : undefined;
244
+ }
245
+
156
246
  function isInsufficientCapacityApiError(errorCode?: string, errorMessage?: string): boolean {
157
247
  const msg = (errorMessage ?? '').toLowerCase();
158
248
  return errorCode === 'VALIDATION_FAILURE' && msg.includes('insufficient capacity');
@@ -355,8 +445,19 @@ function logBookingSourceDebug(
355
445
  }
356
446
  }
357
447
 
358
- function getAuthHeaders(): Record<string, string> {
448
+ function isBookingGatewayUrl(url: string): boolean {
449
+ try {
450
+ return new URL(url, API_BASE).pathname.startsWith(`${BOOKING_GATEWAY_PREFIX}/`);
451
+ } catch {
452
+ return false;
453
+ }
454
+ }
455
+
456
+ function getAuthHeaders(url?: string): Record<string, string> {
359
457
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
458
+ if (url && isBookingGatewayUrl(url)) {
459
+ return withBookingOutboundHeaders(headers);
460
+ }
360
461
  const partnerJwt = partnerPortalBookingJwtGetter();
361
462
  if (partnerJwt) {
362
463
  headers['Authorization'] = `Bearer ${partnerJwt}`;
@@ -393,7 +494,9 @@ function newBookingAttemptId(): string {
393
494
  function getEndpointFromUrl(url: string): string {
394
495
  try {
395
496
  const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
396
- return parsed.pathname;
497
+ return parsed.pathname.startsWith(`${BOOKING_GATEWAY_PREFIX}/`)
498
+ ? parsed.pathname.slice(BOOKING_GATEWAY_PREFIX.length)
499
+ : parsed.pathname;
397
500
  } catch {
398
501
  return '';
399
502
  }
@@ -442,7 +545,10 @@ function getBrowserDiagnostics(): Record<string, number | string | boolean> {
442
545
  function getAvailabilityQueryShape(url: string): Record<string, string | boolean | string[]> | null {
443
546
  try {
444
547
  const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : API_BASE);
445
- if (parsed.pathname !== '/1/get-availabilities') return null;
548
+ const endpoint = parsed.pathname.startsWith(`${BOOKING_GATEWAY_PREFIX}/`)
549
+ ? parsed.pathname.slice(BOOKING_GATEWAY_PREFIX.length)
550
+ : parsed.pathname;
551
+ if (endpoint !== '/1/get-availabilities') return null;
446
552
  const params = parsed.searchParams;
447
553
  return {
448
554
  queryKeys: Array.from(params.keys()).sort(),
@@ -556,6 +662,8 @@ async function collectNetworkFailureProbeResults(
556
662
  ): Promise<Array<Record<string, number | string | boolean | null>>> {
557
663
  if (typeof window === 'undefined') return [];
558
664
  const cacheBust = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
665
+ const telemetryUrl = `${BOOKING_READ_API_BASE}/1/client-telemetry`;
666
+ const telemetryIsSameOrigin = new URL(telemetryUrl, window.location.href).origin === window.location.origin;
559
667
  return Promise.all([
560
668
  runNetworkFailureProbe(
561
669
  'same_origin_asset_head',
@@ -563,11 +671,11 @@ async function collectNetworkFailureProbeResults(
563
671
  { method: 'HEAD', mode: 'same-origin' }
564
672
  ),
565
673
  runNetworkFailureProbe(
566
- 'api_telemetry_cors_post',
567
- `${API_BASE}/1/client-telemetry`,
674
+ telemetryIsSameOrigin ? 'booking_telemetry_same_origin_post' : 'api_telemetry_cors_post',
675
+ telemetryUrl,
568
676
  {
569
677
  method: 'POST',
570
- mode: 'cors',
678
+ mode: telemetryIsSameOrigin ? 'same-origin' : 'cors',
571
679
  headers: {
572
680
  'Content-Type': 'application/json',
573
681
  [BOOKING_CORRELATION_HEADER]: correlationId,
@@ -575,10 +683,11 @@ async function collectNetworkFailureProbeResults(
575
683
  },
576
684
  body: JSON.stringify({
577
685
  event: 'BOOKING_DIALOG_NETWORK_PROBE_PING',
686
+ clientBuildId: ENV.BOOKING_CLIENT_BUILD_ID,
578
687
  endpoint,
579
688
  correlationId,
580
689
  traceparent,
581
- apiBase: API_BASE,
690
+ apiBase: BOOKING_READ_API_BASE,
582
691
  pageUrl: sanitizeBookingSourceUrl(window.location.href) ?? window.location.origin,
583
692
  userAgent: window.navigator.userAgent,
584
693
  online: window.navigator.onLine,
@@ -610,7 +719,7 @@ function bookingRequestTelemetryContext(
610
719
  attemptNumber,
611
720
  maxRetries: BOOKING_GET_MAX_RETRIES,
612
721
  requestUrlPath: endpoint,
613
- requestMode: 'cors',
722
+ requestMode: isBookingGatewayUrl(url) ? 'same-origin' : 'cors',
614
723
  requestCache: 'default',
615
724
  requestCredentials: 'same-origin',
616
725
  requestUrlSearchLength: (() => {
@@ -641,23 +750,22 @@ async function fetchBookingGetWithRetry(
641
750
  }
642
751
  const requestAttemptId = newBookingAttemptId();
643
752
  const headers: Record<string, string> = {
644
- ...getAuthHeaders(),
753
+ ...getAuthHeaders(url),
645
754
  [BOOKING_ATTEMPT_HEADER]: requestAttemptId,
646
755
  };
647
756
  const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
648
- if (isBookingCriticalEndpoint(endpoint)) {
649
- reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_STARTED', {
650
- ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1),
651
- });
652
- }
653
757
  try {
654
758
  const res = await fetch(url, {
655
759
  ...extra,
656
760
  method: 'GET',
657
761
  headers,
658
762
  });
763
+ if (res.status === 401) {
764
+ notifyPartnerPortalAuthorizationFailure();
765
+ }
659
766
  const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAt);
660
767
  if (
768
+ !res.ok ||
661
769
  elapsedMs >= SLOW_REQUEST_THRESHOLD_MS ||
662
770
  (isBookingCriticalEndpoint(endpoint) && Math.random() < SUCCESS_TIMING_SAMPLE_RATE)
663
771
  ) {
@@ -665,6 +773,9 @@ async function fetchBookingGetWithRetry(
665
773
  ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
666
774
  httpStatus: res.status,
667
775
  ok: res.ok,
776
+ sampleRate: !res.ok || elapsedMs >= SLOW_REQUEST_THRESHOLD_MS ? 1 : SUCCESS_TIMING_SAMPLE_RATE,
777
+ slowThresholdMs: SLOW_REQUEST_THRESHOLD_MS,
778
+ ...gatewayResponseMetadata(res),
668
779
  resourceTiming: latestResourceTiming(url),
669
780
  });
670
781
  }
@@ -675,6 +786,12 @@ async function fetchBookingGetWithRetry(
675
786
  attempt < BOOKING_GET_MAX_RETRIES &&
676
787
  (res.status === 502 || res.status === 503 || res.status === 504)
677
788
  ) {
789
+ reportBookingClientTelemetryEvent('BOOKING_DIALOG_REQUEST_RETRYING', {
790
+ ...bookingRequestTelemetryContext(url, endpoint, headers, requestAttemptId, attempt + 1, elapsedMs),
791
+ httpStatus: res.status,
792
+ retryReason: 'retryable_http_status',
793
+ ...gatewayResponseMetadata(res),
794
+ });
678
795
  continue;
679
796
  }
680
797
  return res;
@@ -874,9 +991,16 @@ export interface PricingConfig {
874
991
  cancellationPolicies?: CancellationPolicyOption[];
875
992
  }
876
993
 
877
- export async function fetchProducts(companyId: string): Promise<Product[]> {
994
+ export async function fetchProducts(
995
+ companyId: string,
996
+ options?: { productId?: string }
997
+ ): Promise<Product[]> {
878
998
  const endpoint = '/1/products';
879
- const url = `${API_BASE}${endpoint}?companyId=${encodeURIComponent(companyId)}`;
999
+ const params = new URLSearchParams({ companyId });
1000
+ if (options?.productId?.trim()) {
1001
+ params.set('productId', options.productId.trim());
1002
+ }
1003
+ const url = `${bookingReadApiBase()}${endpoint}?${params}`;
880
1004
  let res: Response;
881
1005
  try {
882
1006
  res = await fetchBookingGetWithRetry(url);
@@ -900,6 +1024,7 @@ export async function fetchProducts(companyId: string): Promise<Product[]> {
900
1024
  message: debugMessage,
901
1025
  httpStatus: res.status,
902
1026
  errorCode: isApiErrorPayload(errPayload) ? errPayload.errorCode : undefined,
1027
+ metadata: gatewayResponseMetadata(res),
903
1028
  });
904
1029
  throw createUserError(endpoint, 'HTTP', debugMessage);
905
1030
  }
@@ -912,6 +1037,7 @@ export async function fetchProducts(companyId: string): Promise<Product[]> {
912
1037
  errorClass: 'APP_ERROR_200',
913
1038
  message: appError,
914
1039
  errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
1040
+ metadata: gatewayResponseMetadata(res),
915
1041
  });
916
1042
  throw createUserError(endpoint, 'APP_ERROR_200', appError);
917
1043
  }
@@ -921,13 +1047,13 @@ export async function fetchProducts(companyId: string): Promise<Product[]> {
921
1047
  }
922
1048
 
923
1049
  export async function getProduct(productId: string, companyId: string): Promise<Product | null> {
924
- const products = await fetchProducts(companyId);
1050
+ const products = await fetchProducts(companyId, { productId });
925
1051
  return products.find((p) => p.productId === productId) ?? null;
926
1052
  }
927
1053
 
928
1054
  export async function getCompany(companyId: string): Promise<Company> {
929
1055
  const res = await fetchBookingGetWithRetry(
930
- `${API_BASE}/1/companies/${encodeURIComponent(companyId)}`,
1056
+ `${bookingReadApiBase()}/1/companies/${encodeURIComponent(companyId)}`,
931
1057
  );
932
1058
  if (!res.ok) {
933
1059
  const err = await res.json();
@@ -965,7 +1091,7 @@ export async function validatePromoCode(
965
1091
  if (normalizedProductId) params.set('productId', normalizedProductId);
966
1092
  if (hasOngoingDiscount === true) params.set('hasOngoingDiscount', 'true');
967
1093
  if (dateTime?.trim()) params.set('dateTime', dateTime.trim());
968
- const res = await fetchBookingGetWithRetry(`${API_BASE}/1/validate-promo?${params}`);
1094
+ const res = await fetchBookingGetWithRetry(`${bookingReadApiBase()}/1/validate-promo?${params}`);
969
1095
  if (!res.ok) {
970
1096
  const err = await res.json();
971
1097
  throw new Error(err.errorMessage || err.error || 'Failed to validate promo code');
@@ -1094,7 +1220,7 @@ export async function getPromoDiscount(
1094
1220
  ) {
1095
1221
  params.set('legacyPromoNewAddOnSubtotal', String(bookingChange.legacyPromoNewAddOnSubtotal));
1096
1222
  }
1097
- const res = await fetchBookingGetWithRetry(`${API_BASE}/1/get-promo-discount?${params}`);
1223
+ const res = await fetchBookingGetWithRetry(`${bookingReadApiBase()}/1/get-promo-discount?${params}`);
1098
1224
  if (!res.ok) {
1099
1225
  const err = await res.json();
1100
1226
  throw new Error(err.errorMessage || err.error || 'Failed to get promo discount');
@@ -1114,7 +1240,7 @@ export async function getAddOns(
1114
1240
  }
1115
1241
  if (options?.preCheckout !== undefined) params.set('preCheckout', String(options.preCheckout));
1116
1242
  if (options?.dateTime?.trim()) params.set('dateTime', options.dateTime.trim());
1117
- const res = await fetchBookingGetWithRetry(`${API_BASE}/1/add-ons?${params}`);
1243
+ const res = await fetchBookingGetWithRetry(`${bookingReadApiBase()}/1/add-ons?${params}`);
1118
1244
  if (!res.ok) {
1119
1245
  const err = await res.json();
1120
1246
  throw new Error(err.errorMessage || err.error || 'Failed to get add-ons');
@@ -1779,6 +1905,7 @@ export async function quoteAdminChangeBookingV2(
1779
1905
  request: ChangeBookingQuoteRequest
1780
1906
  ): Promise<ChangeBookingQuoteResponse> {
1781
1907
  const { bookingReference, lastName: _lastName, ...payload } = request;
1908
+ void _lastName;
1782
1909
  const res = await fetch(
1783
1910
  `${API_BASE}/1/admin/bookings/${encodeURIComponent(bookingReference)}/change/quote-v2`,
1784
1911
  {
@@ -2093,7 +2220,7 @@ export async function getAvailabilities(
2093
2220
  params.set('cancellationPolicyProfileId', cancellationPolicyProfileId);
2094
2221
  }
2095
2222
  const endpoint = '/1/get-availabilities';
2096
- const url = `${API_BASE}${endpoint}?${params}`;
2223
+ const url = `${bookingReadApiBase()}${endpoint}?${params}`;
2097
2224
  let res: Response;
2098
2225
  try {
2099
2226
  res = await fetchBookingGetWithRetry(url);
@@ -2117,6 +2244,7 @@ export async function getAvailabilities(
2117
2244
  message: debugMessage,
2118
2245
  httpStatus: res.status,
2119
2246
  errorCode: isApiErrorPayload(errPayload) ? errPayload.errorCode : undefined,
2247
+ metadata: gatewayResponseMetadata(res),
2120
2248
  });
2121
2249
  throw createUserError(endpoint, 'HTTP', debugMessage);
2122
2250
  }
@@ -2129,6 +2257,7 @@ export async function getAvailabilities(
2129
2257
  errorClass: 'APP_ERROR_200',
2130
2258
  message: appError,
2131
2259
  errorCode: isApiErrorPayload(data) ? data.errorCode : undefined,
2260
+ metadata: gatewayResponseMetadata(res),
2132
2261
  });
2133
2262
  throw createUserError(endpoint, 'APP_ERROR_200', appError);
2134
2263
  }
package/src/lib/env.ts CHANGED
@@ -24,6 +24,17 @@ const getApiUrl = (): string => {
24
24
  return apiUrl;
25
25
  };
26
26
 
27
+ /**
28
+ * Read-only booking traffic can use a same-origin gateway while transactional
29
+ * writes continue to use NEXT_PUBLIC_API_URL.
30
+ */
31
+ const getBookingReadApiUrl = (): string => {
32
+ return process.env.NEXT_PUBLIC_BOOKING_READ_API_URL?.trim() || getApiUrl();
33
+ };
34
+
35
+ const getBookingClientBuildId = (): string =>
36
+ process.env.NEXT_PUBLIC_BOOKING_CLIENT_BUILD_ID?.trim() || 'source-unknown';
37
+
27
38
  const getGoogleMapsApiKey = (): string => {
28
39
  return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? '';
29
40
  };
@@ -92,6 +103,8 @@ export const isLocalhost = (): boolean =>
92
103
 
93
104
  export const ENV = {
94
105
  API_URL: getApiUrl(),
106
+ BOOKING_READ_API_URL: getBookingReadApiUrl(),
107
+ BOOKING_CLIENT_BUILD_ID: getBookingClientBuildId(),
95
108
  GOOGLE_MAPS_API_KEY: getGoogleMapsApiKey(),
96
109
  STRIPE_PUBLISHABLE_KEY: getStripePublishableKey(),
97
110
  BASIC_AUTH: getBasicAuth(),
@@ -75,7 +75,7 @@ interface BookingDialogProviderProps {
75
75
 
76
76
  export function BookingDialogProvider({ children }: BookingDialogProviderProps) {
77
77
  const host = useBookingHostOptional();
78
- const apiUrl = host?.env.API_URL;
78
+ const apiUrl = host?.env.BOOKING_READ_API_URL ?? host?.env.API_URL;
79
79
 
80
80
  const reportSuspiciousBookingProductId = useCallback(
81
81
  (original: string, sanitized: string): void => {
@@ -84,6 +84,7 @@ export function BookingDialogProvider({ children }: BookingDialogProviderProps)
84
84
  const correlationId = getOrCreateBookingCorrelationId();
85
85
  const event = {
86
86
  event: 'BOOKING_DIALOG_SUSPICIOUS_PRODUCT_ID',
87
+ clientBuildId: host?.env.BOOKING_CLIENT_BUILD_ID ?? 'source-unknown',
87
88
  endpoint: '/booking-open',
88
89
  correlationId,
89
90
  originalProductId: original,
@@ -103,7 +104,7 @@ export function BookingDialogProvider({ children }: BookingDialogProviderProps)
103
104
  keepalive: true,
104
105
  }).catch(() => {});
105
106
  },
106
- [apiUrl]
107
+ [apiUrl, host?.env.BOOKING_CLIENT_BUILD_ID]
107
108
  );
108
109
 
109
110
  const [isOpen, setIsOpen] = useState(false);
@@ -10,6 +10,10 @@ export type BookingSlotComponent = ComponentType<any>;
10
10
  */
11
11
  export interface BookingRuntimeEnv {
12
12
  readonly API_URL: string;
13
+ /** Optional same-origin base for public booking reads and client telemetry. */
14
+ readonly BOOKING_READ_API_URL?: string;
15
+ /** Immutable source/build identifier attached to browser telemetry. */
16
+ readonly BOOKING_CLIENT_BUILD_ID: string;
13
17
  readonly COMPANY_ID: string;
14
18
  readonly GOOGLE_MAPS_API_KEY: string;
15
19
  readonly STRIPE_PUBLISHABLE_KEY: string;
@@ -593,7 +593,6 @@
593
593
  "hike": "Perfect for hiking",
594
594
  "canoe": "Rent a canoe",
595
595
  "lunch": "Lunch at Emerald Lake Lodge",
596
- "croissant": "Croissants included",
597
596
  "hotDrinks": "Hot drinks",
598
597
  "blankets": "Cozy blankets",
599
598
  "emeraldLakeEscapeTourLocations": {
@@ -1511,7 +1510,7 @@
1511
1510
  {
1512
1511
  "pagesIncluded": [],
1513
1512
  "question": "Do you provide breakfast during your tours?",
1514
- "answer": "Only private shuttles include a breakfast croissant from a local bakery per person. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1513
+ "answer": "Our private and sunrise shuttles have a breakfast option available for add-on. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1515
1514
  },
1516
1515
  {
1517
1516
  "pagesIncluded": [],
@@ -392,7 +392,6 @@
392
392
  "hike": "Perfecto para senderismo",
393
393
  "canoe": "Alquila una canoa",
394
394
  "lunch": "Almuerzo en Emerald Lake Lodge",
395
- "croissant": "Crusanes incluidos",
396
395
  "hotDrinks": "Bebidas calientes",
397
396
  "blankets": "Mantas calentitas",
398
397
  "emeraldLakeEscapeTourLocations": {
@@ -1309,7 +1308,7 @@
1309
1308
  {
1310
1309
  "pagesIncluded": [],
1311
1310
  "question": "Do you provide breakfast during your tours?",
1312
- "answer": "Only private shuttles include a breakfast croissant from a local bakery per person. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1311
+ "answer": "Our private and sunrise shuttles have a breakfast option available for add-on. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1313
1312
  },
1314
1313
  {
1315
1314
  "pagesIncluded": [],
@@ -392,7 +392,6 @@
392
392
  "hike": "Idéal pour randonner",
393
393
  "canoe": "Location de canoé",
394
394
  "lunch": "Déjeuner aux lodges du lac Émeraude",
395
- "croissant": "Croissants inclus",
396
395
  "hotDrinks": "Boissons chaudes",
397
396
  "blankets": "Mantes chaudes",
398
397
  "emeraldLakeEscapeTourLocations": {
@@ -1309,7 +1308,7 @@
1309
1308
  {
1310
1309
  "pagesIncluded": [],
1311
1310
  "question": "Do you provide breakfast during your tours?",
1312
- "answer": "Only private shuttles include a breakfast croissant from a local bakery per person. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1311
+ "answer": "Our private and sunrise shuttles have a breakfast option available for add-on. Our other tours do not include breakfast, so please be sure to bring your own or purchase a snack at the Moraine Lake Lodge (open from 9 AM - 4 PM)."
1313
1312
  },
1314
1313
  {
1315
1314
  "pagesIncluded": [],
@@ -22,6 +22,7 @@ import {
22
22
  findFirstDateWithBookableAvailability,
23
23
  selectedDateHasVisibleAvailability,
24
24
  } from '../src/components/booking/availability-date-selection';
25
+ import { shouldRevalidateAvailabilityCache } from '../src/components/booking/availability-cache-policy';
25
26
  import {
26
27
  buildChangeBookingServerPreview,
27
28
  labelAdminAmendmentPriceSummaryLines,
@@ -91,7 +92,10 @@ import {
91
92
  buildAdminChangePayNowCheckoutModalData,
92
93
  shouldRestoreAdminChangePaymentChoiceAfterCheckoutClose,
93
94
  } from '../src/components/booking/admin-change-payment-choice-runner';
94
- import { shouldLockExistingAddOnQuantities } from '../src/components/booking/admin-change-flow-state-helpers';
95
+ import {
96
+ filterAdminChangeCalendarAvailabilities,
97
+ shouldLockExistingAddOnQuantities,
98
+ } from '../src/components/booking/admin-change-flow-state-helpers';
95
99
  import { haveAddOnSelectionsChanged } from '../src/components/booking/useChangeBookingSelectionDetails';
96
100
  import {
97
101
  incompatibleAddOnSelections,
@@ -157,6 +161,12 @@ function test(name: string, fn: () => void | Promise<void>): void {
157
161
  }
158
162
  }
159
163
 
164
+ test('availability cache revalidates only when a covered range is stale', () => {
165
+ assert.equal(shouldRevalidateAvailabilityCache(true, false), false);
166
+ assert.equal(shouldRevalidateAvailabilityCache(true, true), true);
167
+ assert.equal(shouldRevalidateAvailabilityCache(false, true), false);
168
+ });
169
+
160
170
  function calendarAvailability(dateTime: string, vacancies: number): Availability {
161
171
  return { dateTime, vacancies, currency: 'CAD' };
162
172
  }
@@ -407,6 +417,48 @@ test('admin availability filtering removes only starts that have passed', () =>
407
417
  );
408
418
  });
409
419
 
420
+ test('admin change availability filtering keeps the in-progress booking date selectable', () => {
421
+ const now = new Date('2026-08-01T13:00:00-06:00');
422
+ const rows = [
423
+ calendarAvailability('2026-07-31T09:00:00-06:00', 4),
424
+ calendarAvailability('2026-08-01T09:00:00-06:00', 4),
425
+ calendarAvailability('2026-08-01T11:00:00-06:00', 4),
426
+ calendarAvailability('2026-08-02T09:00:00-06:00', 4),
427
+ ];
428
+
429
+ assert.deepEqual(
430
+ filterAdminChangeCalendarAvailabilities({
431
+ availabilities: rows,
432
+ originalDate: '2026-08-01',
433
+ companyTimezone: 'America/Edmonton',
434
+ now,
435
+ }).map((row) => row.dateTime),
436
+ [
437
+ '2026-08-01T09:00:00-06:00',
438
+ '2026-08-01T11:00:00-06:00',
439
+ '2026-08-02T09:00:00-06:00',
440
+ ],
441
+ );
442
+ });
443
+
444
+ test('admin change availability filtering keeps the normal time guard without an original date', () => {
445
+ const now = new Date('2026-08-01T13:00:00-06:00');
446
+ const rows = [
447
+ calendarAvailability('2026-08-01T09:00:00-06:00', 4),
448
+ calendarAvailability('2026-08-01T14:00:00-06:00', 4),
449
+ ];
450
+
451
+ assert.deepEqual(
452
+ filterAdminChangeCalendarAvailabilities({
453
+ availabilities: rows,
454
+ originalDate: null,
455
+ companyTimezone: 'America/Edmonton',
456
+ now,
457
+ }).map((row) => row.dateTime),
458
+ ['2026-08-01T14:00:00-06:00'],
459
+ );
460
+ });
461
+
410
462
  test('public availability filtering keeps its stricter advance cutoff', () => {
411
463
  const now = new Date('2026-07-21T17:00:00-06:00');
412
464
  const rows = [