@ticketboothapp/booking 1.2.103 → 1.2.105

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.
@@ -158,22 +158,30 @@ function findMergedAvailabilityForSelection(
158
158
  selected: Availability | null
159
159
  ): Availability | undefined {
160
160
  if (!selected) return undefined;
161
- const optId = selected.productOptionId ?? undefined;
161
+ const optId = getAvailabilityOptionId(selected) || undefined;
162
162
  const dt = selected.dateTime;
163
163
  const availId = selected.availabilityId?.trim();
164
164
  if (availId && optId) {
165
165
  const exact = merged.find(
166
166
  (a) =>
167
167
  a.availabilityId === availId &&
168
- (a.productOptionId === optId || a.productOptionId === undefined)
168
+ getAvailabilityOptionId(a) === optId
169
169
  );
170
170
  if (exact) return exact;
171
171
  }
172
- return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
172
+ return merged.find((a) => a.dateTime === dt && getAvailabilityOptionId(a) === optId);
173
+ }
174
+
175
+ function getAvailabilityOptionId(availability: Availability, fallbackOptionId?: string): string {
176
+ const productOptionId = availability.productOptionId?.trim();
177
+ if (productOptionId) return productOptionId;
178
+ const productId = availability.productId?.trim();
179
+ if (productId?.startsWith('po_')) return productId;
180
+ return fallbackOptionId?.trim() ?? '';
173
181
  }
174
182
 
175
183
  function availabilityWithOptionId(availability: Availability, fallbackOptionId?: string): Availability {
176
- const productOptionId = availability.productOptionId || availability.productId || fallbackOptionId;
184
+ const productOptionId = getAvailabilityOptionId(availability, fallbackOptionId);
177
185
  return productOptionId ? { ...availability, productOptionId } : availability;
178
186
  }
179
187
 
@@ -195,11 +203,15 @@ function mergeAvailabilitiesIntoMap(
195
203
  availabilities: Availability[],
196
204
  ): void {
197
205
  for (const availability of availabilities) {
198
- const key = `${availability.dateTime}-${availability.productOptionId}`;
206
+ const key = getAvailabilityCacheKey(availability);
199
207
  map.set(key, mergeAvailabilityRecord(map.get(key), availability));
200
208
  }
201
209
  }
202
210
 
211
+ function getAvailabilityCacheKey(availability: Availability): string {
212
+ return `${availability.dateTime}-${getAvailabilityOptionId(availability)}`;
213
+ }
214
+
203
215
  function pickDefaultAvailabilityForTimes(
204
216
  times: Availability[],
205
217
  activeOptions: Product['options'],
@@ -208,7 +220,7 @@ function pickDefaultAvailabilityForTimes(
208
220
  const mostPopularOption = activeOptions.find((opt) => opt.mostPopular);
209
221
  const candidate = mostPopularOption
210
222
  ? times.find(
211
- (avail) => avail.productOptionId === mostPopularOption.optionId && avail.vacancies > 0,
223
+ (avail) => getAvailabilityOptionId(avail) === mostPopularOption.optionId && avail.vacancies > 0,
212
224
  )
213
225
  : null;
214
226
  const fallback = times.find((avail) => avail.vacancies > 0);
@@ -271,7 +283,7 @@ function buildPricingFromAvailability(
271
283
  isSimplifiedPricingView: boolean,
272
284
  ) {
273
285
  if (!selectedAvailability || !pricingConfig) return [];
274
- const optionId = selectedAvailability.productOptionId;
286
+ const optionId = getAvailabilityOptionId(selectedAvailability);
275
287
  const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
276
288
  const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
277
289
  const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
@@ -289,11 +301,17 @@ function buildPricingFromAvailability(
289
301
  appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
290
302
  ) => {
291
303
  const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
304
+ const hasOptionScopedPrice = baseInDisplayCurrency > 0;
305
+ const priceCADForBreakdown =
306
+ hasOptionScopedPrice && currency === 'CAD' ? baseInDisplayCurrency : backendPriceCAD;
307
+ const displayCurrencyPriceForFallback = hasOptionScopedPrice
308
+ ? baseInDisplayCurrency
309
+ : backendInDisplayCurrency;
292
310
  const isPublicMode = isSimplifiedPricingView;
293
311
  const breakdown = computePriceBreakdown(
294
312
  pricingConfig,
295
313
  currency,
296
- backendPriceCAD,
314
+ priceCADForBreakdown,
297
315
  basePriceCAD,
298
316
  hasFees,
299
317
  appliedAdjustments,
@@ -301,8 +319,8 @@ function buildPricingFromAvailability(
301
319
  baseInDisplayCurrency,
302
320
  isPublicMode,
303
321
  );
304
- const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
305
- return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
322
+ const price = breakdown?.finalPrice ?? rateToDisplayPrice(displayCurrencyPriceForFallback);
323
+ return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: priceCADForBreakdown };
306
324
  };
307
325
  return (
308
326
  selectedAvailability.rates?.map((rate) => {
@@ -493,8 +511,17 @@ export function NewBookingFlow({
493
511
  const inFlightRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range currently being fetched
494
512
  const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
495
513
  const pendingRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range to fetch when current fetch completes (user navigated during fetch)
514
+ const selectedDateHydrationInFlightKeyRef = useRef<string | null>(null);
496
515
  const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
497
516
  const [selectedDate, setSelectedDate] = useState<string>('');
517
+ const selectedDateRangeExtensionKey = useMemo(() => {
518
+ if (!visibleRange || !selectedDate) return '';
519
+ try {
520
+ return isAfter(parseISO(selectedDate), visibleRange.end) ? selectedDate : '';
521
+ } catch {
522
+ return '';
523
+ }
524
+ }, [selectedDate, visibleRange]);
498
525
  const [isItinerarySticky, setIsItinerarySticky] = useState(false);
499
526
  const isItineraryStickyRef = useRef(false);
500
527
  const [isMobile, setIsMobile] = useState(false);
@@ -699,9 +726,7 @@ export function NewBookingFlow({
699
726
 
700
727
  let mergedOut: Availability[] = [];
701
728
  setAvailabilities((prev) => {
702
- const existingMap = new Map(
703
- prev.map((avail) => [`${avail.dateTime}-${avail.productOptionId}`, avail])
704
- );
729
+ const existingMap = new Map(prev.map((avail) => [getAvailabilityCacheKey(avail), avail]));
705
730
  mergeAvailabilitiesIntoMap(existingMap, allFetchedAvailabilities);
706
731
  mergedOut = Array.from(existingMap.values());
707
732
  return mergedOut;
@@ -728,13 +753,14 @@ export function NewBookingFlow({
728
753
  activeOptionIdsKey,
729
754
  appliedPromoCode,
730
755
  pricingProfileIdForAvailabilities,
756
+ cancellationPolicyProfileIdForAvailabilities,
731
757
  )
732
758
  : null;
733
759
  if (cacheKey && availabilitiesCache) {
734
760
  const existingCache = availabilitiesCache.get(cacheKey);
735
761
  const existingAvailabilities = existingCache?.availabilities ?? [];
736
762
  const mergedAvailabilitiesMap = new Map(
737
- existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
763
+ existingAvailabilities.map((a) => [getAvailabilityCacheKey(a), a])
738
764
  );
739
765
  mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, allFetchedAvailabilities);
740
766
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
@@ -818,7 +844,7 @@ export function NewBookingFlow({
818
844
 
819
845
  let mergedOut: Availability[] = [];
820
846
  setAvailabilities((prev) => {
821
- const merged = new Map(prev.map((availability) => [`${availability.dateTime}-${availability.productOptionId}`, availability]));
847
+ const merged = new Map(prev.map((availability) => [getAvailabilityCacheKey(availability), availability]));
822
848
  mergeAvailabilitiesIntoMap(merged, detailedAvailabilities);
823
849
  mergedOut = Array.from(merged.values());
824
850
  return mergedOut;
@@ -830,13 +856,14 @@ export function NewBookingFlow({
830
856
  activeOptionIdsKey,
831
857
  appliedPromoCode,
832
858
  pricingProfileIdForAvailabilities,
859
+ cancellationPolicyProfileIdForAvailabilities,
833
860
  )
834
861
  : null;
835
862
  if (cacheKey && availabilitiesCache) {
836
863
  const existingCache = availabilitiesCache.get(cacheKey);
837
864
  const mergedAvailabilities = new Map(
838
865
  (existingCache?.availabilities ?? []).map((availability) => [
839
- `${availability.dateTime}-${availability.productOptionId}`,
866
+ getAvailabilityCacheKey(availability),
840
867
  availability,
841
868
  ])
842
869
  );
@@ -918,11 +945,11 @@ export function NewBookingFlow({
918
945
  ? endOfLatestDay
919
946
  : visibleRange.end;
920
947
 
921
- // Ensure we include the selected date if it's after the visible range end
922
- // This handles the case where user selects a date that's not yet in the visible range
923
- if (selectedDate) {
948
+ // Include an externally seeded selected date only when it falls outside the current calendar window.
949
+ // Normal date clicks are already inside visibleRange and should not re-fetch the broad calendar summary.
950
+ if (selectedDateRangeExtensionKey) {
924
951
  try {
925
- const selectedDateObj = parseISO(selectedDate);
952
+ const selectedDateObj = parseISO(selectedDateRangeExtensionKey);
926
953
  if (isAfter(selectedDateObj, clampedEnd)) {
927
954
  clampedEnd = selectedDateObj;
928
955
  }
@@ -938,6 +965,7 @@ export function NewBookingFlow({
938
965
  activeOptionIdsKey,
939
966
  appliedPromoCode,
940
967
  pricingProfileIdForAvailabilities,
968
+ cancellationPolicyProfileIdForAvailabilities,
941
969
  )
942
970
  : null;
943
971
  const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
@@ -1071,7 +1099,7 @@ export function NewBookingFlow({
1071
1099
  // Merge with existing availabilities (avoid duplicates by dateTime + productOptionId)
1072
1100
  setAvailabilities(prev => {
1073
1101
  const existingMap = new Map(
1074
- prev.map(avail => [`${avail.dateTime}-${avail.productOptionId}`, avail])
1102
+ prev.map(avail => [getAvailabilityCacheKey(avail), avail])
1075
1103
  );
1076
1104
 
1077
1105
  // Merge new availabilities - update existing ones or add new ones
@@ -1100,7 +1128,7 @@ export function NewBookingFlow({
1100
1128
  const existingCache = availabilitiesCache.get(cacheKey);
1101
1129
  const existingAvailabilities = existingCache?.availabilities ?? [];
1102
1130
  const mergedAvailabilitiesMap = new Map(
1103
- existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
1131
+ existingAvailabilities.map((a) => [getAvailabilityCacheKey(a), a])
1104
1132
  );
1105
1133
  mergeAvailabilitiesIntoMap(mergedAvailabilitiesMap, allFetchedAvailabilities);
1106
1134
  const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
@@ -1151,7 +1179,7 @@ export function NewBookingFlow({
1151
1179
  activeOptionIdsKey,
1152
1180
  isPrivateShuttle,
1153
1181
  companyTimezone,
1154
- selectedDate,
1182
+ selectedDateRangeExtensionKey,
1155
1183
  appliedPromoCode,
1156
1184
  pricingProfileIdForAvailabilities,
1157
1185
  cancellationPolicyProfileIdForAvailabilities,
@@ -1213,8 +1241,19 @@ export function NewBookingFlow({
1213
1241
 
1214
1242
  useEffect(() => {
1215
1243
  if (!selectedDate || activeOptions.length === 0) return;
1216
- const selectedDateAvailabilities = availabilitiesByDate[selectedDate] ?? [];
1217
- if (!selectedDateAvailabilities.some((availability) => availability.isSummary)) return;
1244
+ if (!selectedAvailability) return;
1245
+ if (!availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone)) return;
1246
+ if (selectedAvailability.isSummary !== true) return;
1247
+ const hydrationKey = [
1248
+ product.productId,
1249
+ selectedDate,
1250
+ activeOptionIdsKey,
1251
+ appliedPromoCode ?? '',
1252
+ pricingProfileIdForAvailabilities ?? '',
1253
+ cancellationPolicyProfileIdForAvailabilities ?? '',
1254
+ ].join('|');
1255
+ if (selectedDateHydrationInFlightKeyRef.current === hydrationKey) return;
1256
+ selectedDateHydrationInFlightKeyRef.current = hydrationKey;
1218
1257
 
1219
1258
  let cancelled = false;
1220
1259
  async function hydrateSelectedDateDetails() {
@@ -1235,6 +1274,7 @@ export function NewBookingFlow({
1235
1274
 
1236
1275
  const result = await getAvailabilities(product.productId, startDateStr, endDateStr, {
1237
1276
  allOptions: true,
1277
+ responseMode: 'booking-day',
1238
1278
  promoCode: appliedPromoCode || undefined,
1239
1279
  ...(pricingProfileIdForAvailabilities
1240
1280
  ? { pricingProfileId: pricingProfileIdForAvailabilities }
@@ -1270,10 +1310,20 @@ export function NewBookingFlow({
1270
1310
  });
1271
1311
 
1272
1312
  setAvailabilities((prev) => {
1273
- const merged = new Map(prev.map((availability) => [`${availability.dateTime}-${availability.productOptionId}`, availability]));
1313
+ const merged = new Map(prev.map((availability) => [getAvailabilityCacheKey(availability), availability]));
1274
1314
  mergeAvailabilitiesIntoMap(merged, detailedAvailabilities);
1275
1315
  return Array.from(merged.values());
1276
1316
  });
1317
+ const updatedSelection = findMergedAvailabilityForSelection(detailedAvailabilities, selectedAvailability);
1318
+ if (updatedSelection && selectedAvailability && shouldSyncSelectedAvailability(selectedAvailability, updatedSelection)) {
1319
+ setSelectedAvailability(updatedSelection);
1320
+ if (selectedReturnOption && updatedSelection.returnOptions) {
1321
+ const updatedReturnOption = updatedSelection.returnOptions.find(
1322
+ (option) => option.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
1323
+ );
1324
+ if (updatedReturnOption) setSelectedReturnOption(updatedReturnOption);
1325
+ }
1326
+ }
1277
1327
 
1278
1328
  const cacheKey = availabilitiesCache
1279
1329
  ? buildAvailabilitiesCacheKey(
@@ -1281,13 +1331,14 @@ export function NewBookingFlow({
1281
1331
  activeOptionIdsKey,
1282
1332
  appliedPromoCode,
1283
1333
  pricingProfileIdForAvailabilities,
1334
+ cancellationPolicyProfileIdForAvailabilities,
1284
1335
  )
1285
1336
  : null;
1286
1337
  if (cacheKey && availabilitiesCache) {
1287
1338
  const existingCache = availabilitiesCache.get(cacheKey);
1288
1339
  const mergedAvailabilities = new Map(
1289
1340
  (existingCache?.availabilities ?? []).map((availability) => [
1290
- `${availability.dateTime}-${availability.productOptionId}`,
1341
+ getAvailabilityCacheKey(availability),
1291
1342
  availability,
1292
1343
  ])
1293
1344
  );
@@ -1308,6 +1359,9 @@ export function NewBookingFlow({
1308
1359
  console.error('Error hydrating availability details:', err);
1309
1360
  }
1310
1361
  } finally {
1362
+ if (selectedDateHydrationInFlightKeyRef.current === hydrationKey) {
1363
+ selectedDateHydrationInFlightKeyRef.current = null;
1364
+ }
1311
1365
  if (!cancelled) setIsFetchingMoreAvailabilities(false);
1312
1366
  }
1313
1367
  }
@@ -1318,8 +1372,9 @@ export function NewBookingFlow({
1318
1372
  };
1319
1373
  }, [
1320
1374
  selectedDate,
1321
- availabilitiesByDate,
1322
- activeOptions,
1375
+ selectedAvailability,
1376
+ selectedReturnOption,
1377
+ activeOptions.length,
1323
1378
  activeOptionIdsKey,
1324
1379
  isPrivateShuttle,
1325
1380
  companyTimezone,
@@ -1468,7 +1523,7 @@ export function NewBookingFlow({
1468
1523
  timesForSelectedDate
1469
1524
  .map(
1470
1525
  (avail) =>
1471
- `${avail.dateTime}|${avail.productOptionId ?? ''}|${avail.vacancies ?? 0}`,
1526
+ `${avail.dateTime}|${getAvailabilityOptionId(avail)}|${avail.vacancies ?? 0}`,
1472
1527
  )
1473
1528
  .join('||'),
1474
1529
  [timesForSelectedDate],
@@ -1478,6 +1533,8 @@ export function NewBookingFlow({
1478
1533
  if (!selectedAvailability || !selectedDate) return false;
1479
1534
  return availabilityMatchesSelectedDate(selectedAvailability, selectedDate, companyTimezone);
1480
1535
  }, [selectedAvailability, selectedDate, companyTimezone]);
1536
+ const selectedBookingOptionsHydrating =
1537
+ selectedAvailabilityMatchesDate && selectedAvailability?.isSummary === true;
1481
1538
 
1482
1539
  useEffect(() => {
1483
1540
  if (hasAppliedInitialValuesRef.current || !initialValues) return;
@@ -1653,7 +1710,9 @@ export function NewBookingFlow({
1653
1710
  const calculateStaySummary = useCallback((returnDateTime: Date): string | null => {
1654
1711
  if (!selectedAvailability) return null;
1655
1712
 
1656
- const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
1713
+ const availabilityProductOptionId =
1714
+ getAvailabilityOptionId(selectedAvailability) ||
1715
+ (activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
1657
1716
  const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
1658
1717
  if (!selectedOption) return null;
1659
1718
 
@@ -1729,7 +1788,9 @@ export function NewBookingFlow({
1729
1788
  // Helper function to compute itinerary display for storage (returns same shape as "Your Itinerary" box)
1730
1789
  const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
1731
1790
  if (!selectedAvailability) return null;
1732
- const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
1791
+ const availabilityProductOptionId =
1792
+ getAvailabilityOptionId(selectedAvailability) ||
1793
+ (activeOptions.length === 1 ? activeOptions[0]?.optionId : undefined);
1733
1794
  const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
1734
1795
  if (!selectedOption) return null;
1735
1796
  const tourStartTime = parseISO(selectedAvailability.dateTime);
@@ -1899,7 +1960,7 @@ export function NewBookingFlow({
1899
1960
  // Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
1900
1961
  const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
1901
1962
  if (!pricingConfig) return null;
1902
- const selectedOption = activeOptions.find(opt => opt.optionId === selectedAvailability?.productOptionId);
1963
+ const selectedOption = activeOptions.find(opt => opt.optionId === (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''));
1903
1964
  const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
1904
1965
  const isPublicMode = isSimplifiedPricingView;
1905
1966
  return computePriceBreakdown(
@@ -2122,7 +2183,7 @@ export function NewBookingFlow({
2122
2183
  if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) return '';
2123
2184
  const companyId = product.companyId ?? env.COMPANY_ID;
2124
2185
  if (!companyId) return '';
2125
- const optionId = selectedAvailability.productOptionId;
2186
+ const optionId = getAvailabilityOptionId(selectedAvailability);
2126
2187
  if (!optionId || !quantitiesSignature) return '';
2127
2188
  return [
2128
2189
  appliedPromoCode,
@@ -2138,7 +2199,7 @@ export function NewBookingFlow({
2138
2199
  }, [
2139
2200
  appliedPromoCode,
2140
2201
  selectedAvailability?.dateTime,
2141
- selectedAvailability?.productOptionId,
2202
+ selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : '',
2142
2203
  selectedAvailability?.availabilityId,
2143
2204
  totalQuantity,
2144
2205
  product.companyId,
@@ -2334,9 +2395,9 @@ export function NewBookingFlow({
2334
2395
  useEffect(() => {
2335
2396
  if (selectedAvailability && availabilities.length > 0) {
2336
2397
  const updatedAvailability = availabilities.find(
2337
- avail =>
2398
+ avail =>
2338
2399
  avail.dateTime === selectedAvailability.dateTime &&
2339
- avail.productOptionId === selectedAvailability.productOptionId
2400
+ getAvailabilityOptionId(avail) === getAvailabilityOptionId(selectedAvailability)
2340
2401
  );
2341
2402
  if (updatedAvailability && shouldSyncSelectedAvailability(selectedAvailability, updatedAvailability)) {
2342
2403
  setSelectedAvailability(updatedAvailability);
@@ -2394,7 +2455,7 @@ export function NewBookingFlow({
2394
2455
  ]);
2395
2456
 
2396
2457
  // Fetch add-ons when availability (product option) is selected; clear selections when option changes
2397
- const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
2458
+ const availabilityProductOptionId = selectedAvailability ? getAvailabilityOptionId(selectedAvailability) || null : null;
2398
2459
  const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
2399
2460
  useEffect(() => {
2400
2461
  if (!availabilityProductOptionId || !product.companyId) {
@@ -2523,8 +2584,8 @@ export function NewBookingFlow({
2523
2584
  [selectedAvailability]
2524
2585
  );
2525
2586
  const selectedAvailabilityKey = useMemo(
2526
- () => `${selectedAvailability?.dateTime ?? ''}::${selectedAvailability?.productOptionId ?? ''}`,
2527
- [selectedAvailability?.dateTime, selectedAvailability?.productOptionId]
2587
+ () => `${selectedAvailability?.dateTime ?? ''}::${selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''}`,
2588
+ [selectedAvailability]
2528
2589
  );
2529
2590
  // Remember where promo was successfully applied to avoid self-clearing on same selection.
2530
2591
  const promoAppliedSelectionKeyRef = useRef<string | null>(null);
@@ -2715,8 +2776,7 @@ export function NewBookingFlow({
2715
2776
  .map(([category, count]) => ({ category, count }));
2716
2777
 
2717
2778
  // Get the productOptionId from the selected availability (we tagged it when fetching)
2718
- const availabilityProductOptionId = selectedAvailability.productOptionId
2719
- || activeOptions[0]?.optionId;
2779
+ const availabilityProductOptionId = getAvailabilityOptionId(selectedAvailability);
2720
2780
 
2721
2781
  if (!availabilityProductOptionId) {
2722
2782
  setError('No product option selected');
@@ -3478,7 +3538,15 @@ export function NewBookingFlow({
3478
3538
  )}
3479
3539
 
3480
3540
  {/* Select return time — wait for hydrated day details so summary refetches do not flash this block */}
3481
- {selectedAvailabilityMatchesDate &&
3541
+ {selectedBookingOptionsHydrating && (
3542
+ <div className="flex items-center justify-center gap-3 rounded-lg border border-stone-200 bg-white p-4 text-stone-600">
3543
+ <div className="booking-loading-spinner" aria-hidden />
3544
+ <div>{t('booking.loadingTimes')}</div>
3545
+ </div>
3546
+ )}
3547
+
3548
+ {!selectedBookingOptionsHydrating &&
3549
+ selectedAvailabilityMatchesDate &&
3482
3550
  selectedAvailability &&
3483
3551
  !selectedAvailability.isSummary &&
3484
3552
  selectedAvailability.returnOptions &&
@@ -3502,7 +3570,7 @@ export function NewBookingFlow({
3502
3570
  />
3503
3571
  )}
3504
3572
 
3505
- {selectedDate && selectedAvailability && afterItinerary
3573
+ {!selectedBookingOptionsHydrating && selectedDate && selectedAvailability && afterItinerary
3506
3574
  ? typeof afterItinerary === 'function'
3507
3575
  ? afterItinerary({
3508
3576
  selectedDate,
@@ -3513,7 +3581,8 @@ export function NewBookingFlow({
3513
3581
  : null}
3514
3582
 
3515
3583
  {/* Cancellation policy selection - all options from config, sorted by cheapest first. Also show when forced by promo. */}
3516
- {selectedAvailabilityMatchesDate &&
3584
+ {!selectedBookingOptionsHydrating &&
3585
+ selectedAvailabilityMatchesDate &&
3517
3586
  selectedAvailability &&
3518
3587
  ((pricingConfig?.cancellationPolicies?.length ?? 0) > 0 || forcedCancellationPolicy) &&
3519
3588
  (() => {
@@ -3540,7 +3609,7 @@ export function NewBookingFlow({
3540
3609
  })()}
3541
3610
 
3542
3611
  {/* Ticket Selection */}
3543
- {selectedAvailability && (
3612
+ {!selectedBookingOptionsHydrating && selectedAvailability && (
3544
3613
  <TicketSelector
3545
3614
  pricing={pricing}
3546
3615
  quantities={quantities}
@@ -3564,7 +3633,7 @@ export function NewBookingFlow({
3564
3633
  )}
3565
3634
 
3566
3635
  {/* Add-ons — optional extras for the selected product option */}
3567
- {selectedAvailability && totalQuantity > 0 && addOns.length > 0 && (
3636
+ {!selectedBookingOptionsHydrating && selectedAvailability && totalQuantity > 0 && addOns.length > 0 && (
3568
3637
  <AddOnsSection
3569
3638
  addOns={addOns}
3570
3639
  addOnSelections={addOnSelections}
@@ -3576,7 +3645,7 @@ export function NewBookingFlow({
3576
3645
  )}
3577
3646
 
3578
3647
  {/* Total and Checkout — shared PriceSummary component */}
3579
- {selectedAvailability && (
3648
+ {!selectedBookingOptionsHydrating && selectedAvailability && (
3580
3649
  <>
3581
3650
  <CheckoutForm
3582
3651
  priceSummaryLines={displayCheckoutPriceSummaryLines}
@@ -251,6 +251,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
251
251
  const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
252
252
  const [showSkipWarning, setShowSkipWarning] = useState(false);
253
253
  const [dontKnowFilterActive, setDontKnowFilterActive] = useState(false);
254
+ const [mapsAuthError, setMapsAuthError] = useState(false);
254
255
  const mapRef = useRef<google.maps.Map | null>(null);
255
256
  const highlightedPickupLocationIdSet = useMemo(
256
257
  () => new Set(highlightIds),
@@ -285,8 +286,26 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
285
286
  googleMapsApiKey,
286
287
  libraries,
287
288
  });
289
+ const canUseMap = isLoaded && !loadError && !mapsAuthError;
288
290
 
289
291
  // ============ Effects ============
292
+
293
+ useEffect(() => {
294
+ const mapsWindow = window as typeof window & { gm_authFailure?: () => void };
295
+ const previousAuthFailure = mapsWindow.gm_authFailure;
296
+
297
+ const handleAuthFailure = () => {
298
+ setMapsAuthError(true);
299
+ previousAuthFailure?.();
300
+ };
301
+ mapsWindow.gm_authFailure = handleAuthFailure;
302
+
303
+ return () => {
304
+ if (mapsWindow.gm_authFailure === handleAuthFailure) {
305
+ mapsWindow.gm_authFailure = previousAuthFailure;
306
+ }
307
+ };
308
+ }, []);
290
309
 
291
310
  // Apply filter pills to pickup locations
292
311
  const filteredPickupLocations = useMemo(() => {
@@ -868,16 +887,20 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
868
887
 
869
888
  // ============ Render ============
870
889
 
871
- if (loadError) {
872
- return (
873
- <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
874
- Error loading Google Maps. Please check your API key.
875
- </div>
876
- );
877
- }
878
-
879
890
  return (
880
891
  <div className="space-y-3">
892
+ {loadError && (
893
+ <div className="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 text-sm">
894
+ Google Maps could not load. You can still select a pickup location from the list below.
895
+ </div>
896
+ )}
897
+
898
+ {mapsAuthError && (
899
+ <div className="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 text-sm">
900
+ Google Maps is not authorized for this website. You can still select a pickup location from the list below.
901
+ </div>
902
+ )}
903
+
881
904
  {!hideTitle && (
882
905
  <div className="space-y-0.5">
883
906
  <h2 className="text-2xl font-bold text-stone-900">
@@ -1038,7 +1061,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1038
1061
  </div>
1039
1062
 
1040
1063
  {/* Filter Pills + I don't know pill - horizontal scroll on mobile */}
1041
- {isLoaded && pickupLocations.length > 0 && (
1064
+ {pickupLocations.length > 0 && (
1042
1065
  <div className={styles.filterPillsScroll}>
1043
1066
  <FilterPills
1044
1067
  pickupLocations={pickupLocations}
@@ -1112,7 +1135,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1112
1135
  )}
1113
1136
 
1114
1137
  {/* Two-column layout: list left, map right - list height matches map */}
1115
- {isLoaded && pickupLocations.length > 0 && (
1138
+ {pickupLocations.length > 0 && (
1116
1139
  <div className={styles.twoColLayout}>
1117
1140
  {/* Left column: scrollable list of pickup locations + I don't know */}
1118
1141
  <div className={styles.leftColumn}>
@@ -1235,13 +1258,14 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1235
1258
  {/* Right column: map */}
1236
1259
  <div className={styles.rightColumn}>
1237
1260
  <div className={styles.mapWrapper}>
1238
- <GoogleMap
1239
- mapContainerClassName="w-full h-full rounded-lg overflow-hidden"
1240
- center={mapCenter}
1241
- zoom={mapZoom}
1242
- options={mapOptions}
1243
- onLoad={onMapLoad}
1244
- >
1261
+ {canUseMap ? (
1262
+ <GoogleMap
1263
+ mapContainerClassName="w-full h-full rounded-lg overflow-hidden"
1264
+ center={mapCenter}
1265
+ zoom={mapZoom}
1266
+ options={mapOptions}
1267
+ onLoad={onMapLoad}
1268
+ >
1245
1269
  {/* Markers for filtered locations */}
1246
1270
  {nearbyLocations.map((location) => {
1247
1271
  if (!location.coordinates) return null;
@@ -1480,7 +1504,12 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
1480
1504
  cursor="default"
1481
1505
  />
1482
1506
  ))}
1483
- </GoogleMap>
1507
+ </GoogleMap>
1508
+ ) : (
1509
+ <div className="h-full min-h-[280px] flex items-center justify-center rounded-lg bg-stone-50 border border-stone-200 p-4 text-center text-sm text-stone-600">
1510
+ Google Maps is unavailable right now. The pickup list is still available.
1511
+ </div>
1512
+ )}
1484
1513
  </div>
1485
1514
  </div>
1486
1515
  </div>