@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.
- package/package.json +1 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +80 -49
- package/src/components/booking/ChangeBookingFlow.tsx +79 -45
- package/src/components/booking/NewBookingFlow.tsx +117 -48
- package/src/components/booking/PickupLocationSelector.tsx +47 -18
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +68 -34
- package/src/contexts/AvailabilitiesCacheContext.tsx +4 -2
- package/src/lib/booking/pricing.ts +5 -1
- package/src/lib/booking-api.ts +2 -0
|
@@ -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
|
|
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
|
|
168
|
+
getAvailabilityOptionId(a) === optId
|
|
169
169
|
);
|
|
170
170
|
if (exact) return exact;
|
|
171
171
|
}
|
|
172
|
-
return merged.find((a) => a.dateTime === dt && a
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
305
|
-
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD:
|
|
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) => [
|
|
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) => [
|
|
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
|
-
|
|
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
|
-
//
|
|
922
|
-
//
|
|
923
|
-
if (
|
|
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(
|
|
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 => [
|
|
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) => [
|
|
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
|
-
|
|
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
|
-
|
|
1217
|
-
if (!
|
|
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) => [
|
|
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
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2398
|
+
avail =>
|
|
2338
2399
|
avail.dateTime === selectedAvailability.dateTime &&
|
|
2339
|
-
avail
|
|
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
|
|
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
|
|
2527
|
-
[selectedAvailability
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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>
|