@ticketboothapp/booking 1.2.113 → 1.2.115
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 +2 -1
- package/src/components/booking/AdminChangeBookingFlow.tsx +18 -3
- package/src/components/booking/ChangeBookingFlow.tsx +18 -3
- package/src/components/booking/NewBookingFlow.tsx +31 -11
- package/src/components/booking/PickupLocationDialog.tsx +1 -1
- package/src/components/booking/PickupLocationSelector.tsx +34 -38
- package/src/hooks/usePlacesAutocompleteSearch.ts +133 -0
- package/src/lib/booking/places-api.ts +25 -7
- package/src/lib/booking-api.ts +37 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ticketboothapp/booking",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.115",
|
|
4
4
|
"private": false,
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"**/*.css",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./providers/booking-dialog-provider": "./src/providers/booking-dialog-provider.tsx",
|
|
20
20
|
"./hooks/useBookingSourceMetadataFromLocation": "./src/hooks/useBookingSourceMetadataFromLocation.ts",
|
|
21
21
|
"./hooks/useIsBookingLaunchLive": "./src/hooks/useIsBookingLaunchLive.ts",
|
|
22
|
+
"./hooks/usePlacesAutocompleteSearch": "./src/hooks/usePlacesAutocompleteSearch.ts",
|
|
22
23
|
"./viavia/catalog": "./src/constants/products.ts",
|
|
23
24
|
"./viavia/images": "./src/constants/images.ts",
|
|
24
25
|
"./viavia/product-descriptions": "./src/lib/product-descriptions.ts",
|
|
@@ -3351,8 +3351,9 @@ export function AdminChangeBookingFlow({
|
|
|
3351
3351
|
/** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
|
|
3352
3352
|
const promoDiscountParamsRef = useRef({
|
|
3353
3353
|
selectedAvailability: null as Availability | null,
|
|
3354
|
-
ticketLineItems: [] as Array<{ category: string; qty: number }>,
|
|
3354
|
+
ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
|
|
3355
3355
|
effectiveSubtotal: 0,
|
|
3356
|
+
displayedProductFeeTotal: 0,
|
|
3356
3357
|
appliedPromoCode: null as string | null,
|
|
3357
3358
|
changeBookingPromo: null as { priorAmount?: number } | null,
|
|
3358
3359
|
});
|
|
@@ -3377,6 +3378,11 @@ export function AdminChangeBookingFlow({
|
|
|
3377
3378
|
selectedAvailability.availabilityId ?? '',
|
|
3378
3379
|
currency,
|
|
3379
3380
|
quantitiesSignature,
|
|
3381
|
+
ticketLineItemsForChangeFlowDisplay
|
|
3382
|
+
.map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
|
|
3383
|
+
.sort()
|
|
3384
|
+
.join('|'),
|
|
3385
|
+
String(Math.round(feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
|
|
3380
3386
|
String(Math.round(effectiveSubtotalForCheckout * 100)),
|
|
3381
3387
|
lockedPromoCode ? String(Math.round(originalReceiptPromoAdjustment.amount * 100)) : '',
|
|
3382
3388
|
].join('::');
|
|
@@ -3390,6 +3396,8 @@ export function AdminChangeBookingFlow({
|
|
|
3390
3396
|
product.productId,
|
|
3391
3397
|
currency,
|
|
3392
3398
|
quantitiesSignature,
|
|
3399
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
3400
|
+
feeLineItems,
|
|
3393
3401
|
effectiveSubtotalForCheckout,
|
|
3394
3402
|
lockedPromoCode,
|
|
3395
3403
|
originalReceiptPromoAdjustment.amount,
|
|
@@ -3397,8 +3405,9 @@ export function AdminChangeBookingFlow({
|
|
|
3397
3405
|
|
|
3398
3406
|
promoDiscountParamsRef.current = {
|
|
3399
3407
|
selectedAvailability,
|
|
3400
|
-
ticketLineItems:
|
|
3408
|
+
ticketLineItems: ticketLineItemsForChangeFlowDisplay.map((l) => ({ category: l.category, qty: l.qty, itemTotal: l.itemTotal })),
|
|
3401
3409
|
effectiveSubtotal: effectiveSubtotalForCheckout,
|
|
3410
|
+
displayedProductFeeTotal: feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0),
|
|
3402
3411
|
appliedPromoCode,
|
|
3403
3412
|
changeBookingPromo: lockedPromoCode
|
|
3404
3413
|
? { priorAmount: originalReceiptPromoAdjustment.amount }
|
|
@@ -3421,6 +3430,7 @@ export function AdminChangeBookingFlow({
|
|
|
3421
3430
|
selectedAvailability: sel,
|
|
3422
3431
|
ticketLineItems: lines,
|
|
3423
3432
|
effectiveSubtotal: sub,
|
|
3433
|
+
displayedProductFeeTotal,
|
|
3424
3434
|
appliedPromoCode: code,
|
|
3425
3435
|
changeBookingPromo,
|
|
3426
3436
|
} = promoDiscountParamsRef.current;
|
|
@@ -3445,8 +3455,13 @@ export function AdminChangeBookingFlow({
|
|
|
3445
3455
|
? {
|
|
3446
3456
|
forBookingChange: true,
|
|
3447
3457
|
priorPromoDiscountAmount: changeBookingPromo.priorAmount,
|
|
3458
|
+
displayedTicketLines: lines,
|
|
3459
|
+
displayedProductFeeTotal,
|
|
3460
|
+
}
|
|
3461
|
+
: {
|
|
3462
|
+
displayedTicketLines: lines,
|
|
3463
|
+
displayedProductFeeTotal,
|
|
3448
3464
|
}
|
|
3449
|
-
: undefined
|
|
3450
3465
|
)
|
|
3451
3466
|
.then((res) => {
|
|
3452
3467
|
if (cancelled) return;
|
|
@@ -3173,8 +3173,9 @@ export function ChangeBookingFlow({
|
|
|
3173
3173
|
/** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
|
|
3174
3174
|
const promoDiscountParamsRef = useRef({
|
|
3175
3175
|
selectedAvailability: null as Availability | null,
|
|
3176
|
-
ticketLineItems: [] as Array<{ category: string; qty: number }>,
|
|
3176
|
+
ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
|
|
3177
3177
|
effectiveSubtotal: 0,
|
|
3178
|
+
displayedProductFeeTotal: 0,
|
|
3178
3179
|
appliedPromoCode: null as string | null,
|
|
3179
3180
|
/** Change flow: pass BE stored promo discount for expired / fixed promo locking. */
|
|
3180
3181
|
changeBookingPromo: null as { priorAmount?: number } | null,
|
|
@@ -3195,6 +3196,11 @@ export function ChangeBookingFlow({
|
|
|
3195
3196
|
selectedAvailability.availabilityId ?? '',
|
|
3196
3197
|
currency,
|
|
3197
3198
|
quantitiesSignature,
|
|
3199
|
+
ticketLineItemsForChangeFlowDisplay
|
|
3200
|
+
.map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
|
|
3201
|
+
.sort()
|
|
3202
|
+
.join('|'),
|
|
3203
|
+
String(Math.round(feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
|
|
3198
3204
|
String(Math.round(effectiveSubtotal * 100)),
|
|
3199
3205
|
lockedPromoCode ? String(Math.round((originalReceipt?.promoAmount ?? 0) * 100)) : '',
|
|
3200
3206
|
].join('::');
|
|
@@ -3208,6 +3214,8 @@ export function ChangeBookingFlow({
|
|
|
3208
3214
|
product.productId,
|
|
3209
3215
|
currency,
|
|
3210
3216
|
quantitiesSignature,
|
|
3217
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
3218
|
+
feeLineItems,
|
|
3211
3219
|
effectiveSubtotal,
|
|
3212
3220
|
lockedPromoCode,
|
|
3213
3221
|
originalReceipt?.promoAmount,
|
|
@@ -3215,8 +3223,9 @@ export function ChangeBookingFlow({
|
|
|
3215
3223
|
|
|
3216
3224
|
promoDiscountParamsRef.current = {
|
|
3217
3225
|
selectedAvailability,
|
|
3218
|
-
ticketLineItems:
|
|
3226
|
+
ticketLineItems: ticketLineItemsForChangeFlowDisplay.map((l) => ({ category: l.category, qty: l.qty, itemTotal: l.itemTotal })),
|
|
3219
3227
|
effectiveSubtotal,
|
|
3228
|
+
displayedProductFeeTotal: feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0),
|
|
3220
3229
|
appliedPromoCode,
|
|
3221
3230
|
changeBookingPromo: lockedPromoCode
|
|
3222
3231
|
? { priorAmount: originalReceipt?.promoAmount }
|
|
@@ -3239,6 +3248,7 @@ export function ChangeBookingFlow({
|
|
|
3239
3248
|
selectedAvailability: sel,
|
|
3240
3249
|
ticketLineItems: lines,
|
|
3241
3250
|
effectiveSubtotal: sub,
|
|
3251
|
+
displayedProductFeeTotal,
|
|
3242
3252
|
appliedPromoCode: code,
|
|
3243
3253
|
changeBookingPromo,
|
|
3244
3254
|
} = promoDiscountParamsRef.current;
|
|
@@ -3263,8 +3273,13 @@ export function ChangeBookingFlow({
|
|
|
3263
3273
|
? {
|
|
3264
3274
|
forBookingChange: true,
|
|
3265
3275
|
priorPromoDiscountAmount: changeBookingPromo.priorAmount,
|
|
3276
|
+
displayedTicketLines: lines,
|
|
3277
|
+
displayedProductFeeTotal,
|
|
3278
|
+
}
|
|
3279
|
+
: {
|
|
3280
|
+
displayedTicketLines: lines,
|
|
3281
|
+
displayedProductFeeTotal,
|
|
3266
3282
|
}
|
|
3267
|
-
: undefined
|
|
3268
3283
|
)
|
|
3269
3284
|
.then((res) => {
|
|
3270
3285
|
if (cancelled) return;
|
|
@@ -2325,8 +2325,9 @@ export function NewBookingFlow({
|
|
|
2325
2325
|
/** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
|
|
2326
2326
|
const promoDiscountParamsRef = useRef({
|
|
2327
2327
|
selectedAvailability: null as Availability | null,
|
|
2328
|
-
ticketLineItems: [] as Array<{ category: string; qty: number }>,
|
|
2328
|
+
ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
|
|
2329
2329
|
effectiveSubtotal: 0,
|
|
2330
|
+
displayedProductFeeTotal: 0,
|
|
2330
2331
|
appliedPromoCode: null as string | null,
|
|
2331
2332
|
});
|
|
2332
2333
|
|
|
@@ -2345,6 +2346,11 @@ export function NewBookingFlow({
|
|
|
2345
2346
|
selectedAvailability.availabilityId ?? '',
|
|
2346
2347
|
currency,
|
|
2347
2348
|
quantitiesSignature,
|
|
2349
|
+
ticketLineItems
|
|
2350
|
+
.map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
|
|
2351
|
+
.sort()
|
|
2352
|
+
.join('|'),
|
|
2353
|
+
String(Math.round(feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
|
|
2348
2354
|
String(Math.round(effectiveSubtotal * 100)),
|
|
2349
2355
|
].join('::');
|
|
2350
2356
|
}, [
|
|
@@ -2357,13 +2363,16 @@ export function NewBookingFlow({
|
|
|
2357
2363
|
product.productId,
|
|
2358
2364
|
currency,
|
|
2359
2365
|
quantitiesSignature,
|
|
2366
|
+
ticketLineItems,
|
|
2367
|
+
feeLineItems,
|
|
2360
2368
|
effectiveSubtotal,
|
|
2361
2369
|
]);
|
|
2362
2370
|
|
|
2363
2371
|
promoDiscountParamsRef.current = {
|
|
2364
2372
|
selectedAvailability,
|
|
2365
|
-
ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
|
|
2373
|
+
ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty, itemTotal: l.itemTotal })),
|
|
2366
2374
|
effectiveSubtotal,
|
|
2375
|
+
displayedProductFeeTotal: feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0),
|
|
2367
2376
|
appliedPromoCode,
|
|
2368
2377
|
};
|
|
2369
2378
|
|
|
@@ -2383,11 +2392,12 @@ export function NewBookingFlow({
|
|
|
2383
2392
|
selectedAvailability: sel,
|
|
2384
2393
|
ticketLineItems: lines,
|
|
2385
2394
|
effectiveSubtotal: sub,
|
|
2395
|
+
displayedProductFeeTotal,
|
|
2386
2396
|
appliedPromoCode: code,
|
|
2387
2397
|
} = promoDiscountParamsRef.current;
|
|
2388
2398
|
if (!code || !sel) return;
|
|
2389
2399
|
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
2390
|
-
const optionId = sel
|
|
2400
|
+
const optionId = getAvailabilityOptionId(sel);
|
|
2391
2401
|
if (!companyId || !optionId) return;
|
|
2392
2402
|
const items = lines.map((l) => ({ category: l.category, qty: l.qty }));
|
|
2393
2403
|
if (items.length === 0) return;
|
|
@@ -2401,7 +2411,13 @@ export function NewBookingFlow({
|
|
|
2401
2411
|
currency,
|
|
2402
2412
|
items,
|
|
2403
2413
|
sel.dateTime,
|
|
2404
|
-
sub
|
|
2414
|
+
sub,
|
|
2415
|
+
{
|
|
2416
|
+
availabilityCount: sel.vacancies,
|
|
2417
|
+
totalCapacity: sel.totalCapacity,
|
|
2418
|
+
displayedTicketLines: lines,
|
|
2419
|
+
displayedProductFeeTotal,
|
|
2420
|
+
}
|
|
2405
2421
|
)
|
|
2406
2422
|
.then((res) => {
|
|
2407
2423
|
if (cancelled) return;
|
|
@@ -2762,7 +2778,7 @@ export function NewBookingFlow({
|
|
|
2762
2778
|
setPromoCodeError('');
|
|
2763
2779
|
setPromoCodeValidating(true);
|
|
2764
2780
|
try {
|
|
2765
|
-
const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
|
|
2781
|
+
const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount, selectedAvailability.dateTime);
|
|
2766
2782
|
if (result.valid) {
|
|
2767
2783
|
promoAppliedSelectionKeyRef.current = selectedAvailabilityKey;
|
|
2768
2784
|
setAppliedPromoCode(code);
|
|
@@ -2797,29 +2813,33 @@ export function NewBookingFlow({
|
|
|
2797
2813
|
}
|
|
2798
2814
|
}, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t, selectedAvailabilityKey, selectedAvailability, totalQuantity]);
|
|
2799
2815
|
|
|
2800
|
-
// When user
|
|
2816
|
+
// When user changes selected booking context, re-validate the applied promo against the new date/deal context.
|
|
2801
2817
|
useEffect(() => {
|
|
2802
|
-
if (!appliedPromoCode || !
|
|
2818
|
+
if (!appliedPromoCode || !selectedAvailability) return;
|
|
2803
2819
|
// Only run this guard when user moved away from the selection where promo was applied.
|
|
2804
2820
|
// On the same selection, "deal" adjustments can be promo-driven and would self-clear incorrectly.
|
|
2805
2821
|
if (promoAppliedSelectionKeyRef.current === selectedAvailabilityKey) {
|
|
2806
2822
|
return;
|
|
2807
2823
|
}
|
|
2808
2824
|
let cancelled = false;
|
|
2809
|
-
validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId,
|
|
2825
|
+
validatePromoCode(appliedPromoCode, product.companyId ?? '', product.productId, hasOngoingDiscount, selectedAvailability.dateTime).then((result) => {
|
|
2810
2826
|
if (cancelled) return;
|
|
2811
|
-
if (!result.valid
|
|
2827
|
+
if (!result.valid) {
|
|
2812
2828
|
promoAppliedSelectionKeyRef.current = null;
|
|
2813
2829
|
setAppliedPromoCode(null);
|
|
2814
2830
|
setPromoCodeInput(appliedPromoCode);
|
|
2815
|
-
|
|
2831
|
+
const errorMsg =
|
|
2832
|
+
result.error === 'Promo codes cannot be stacked with deals'
|
|
2833
|
+
? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
|
|
2834
|
+
: (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
|
|
2835
|
+
setPromoCodeError(errorMsg);
|
|
2816
2836
|
setForcedCancellationPolicy(null);
|
|
2817
2837
|
setCancellationPolicyId(null);
|
|
2818
2838
|
fetchedRangesRef.current = [];
|
|
2819
2839
|
}
|
|
2820
2840
|
});
|
|
2821
2841
|
return () => { cancelled = true; };
|
|
2822
|
-
}, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t, selectedAvailabilityKey]);
|
|
2842
|
+
}, [hasOngoingDiscount, appliedPromoCode, product.companyId, product.productId, t, selectedAvailabilityKey, selectedAvailability]);
|
|
2823
2843
|
|
|
2824
2844
|
// Ref to avoid effect re-running when handleApplyPromo identity changes (t changes every render)
|
|
2825
2845
|
const handleApplyPromoRef = useRef(handleApplyPromo);
|
|
@@ -14,7 +14,7 @@ import styles from './PickupLocationDialog.module.css';
|
|
|
14
14
|
|
|
15
15
|
const SAMSON_MALL_PICKUP_LOCATION_ID = 'loc_afkvmlNRwRqZ';
|
|
16
16
|
const SAMSON_MALL_PARKING_DISCLAIMER =
|
|
17
|
-
'No public parking at this pickup location.
|
|
17
|
+
'No public parking at this pickup location. This pickup location is meant to serve guests staying at the HI Lake Louise Alpine Hostel, Lake Louise Inn, or Lake Louise Campground and can walk to Samson Mall for pickup due to parking restrictions in the town of Lake Louise.';
|
|
18
18
|
|
|
19
19
|
function findProductWithPickupLocations(
|
|
20
20
|
products: Product[],
|
|
@@ -14,10 +14,10 @@ import {
|
|
|
14
14
|
isExactMatch,
|
|
15
15
|
} from '../../lib/booking/location-calculations';
|
|
16
16
|
import {
|
|
17
|
-
getAutocompleteSuggestions,
|
|
18
17
|
getPlaceDetails,
|
|
19
18
|
type AutocompleteSuggestion,
|
|
20
19
|
} from '../../lib/booking/places-api';
|
|
20
|
+
import { usePlacesAutocompleteSearch } from '../../hooks/usePlacesAutocompleteSearch';
|
|
21
21
|
import {
|
|
22
22
|
createDistanceMarkerIcon,
|
|
23
23
|
createPinMarkerIcon,
|
|
@@ -79,7 +79,7 @@ const SEARCHED_LOCATION_ZOOM = 14;
|
|
|
79
79
|
const MAX_ZOOM = 15;
|
|
80
80
|
const SAMSON_MALL_PICKUP_LOCATION_ID = 'loc_afkvmlNRwRqZ';
|
|
81
81
|
const SAMSON_MALL_PARKING_DISCLAIMER =
|
|
82
|
-
'No public parking at this pickup location.
|
|
82
|
+
'No public parking at this pickup location. This pickup location is meant to serve guests staying at the HI Lake Louise Alpine Hostel, Lake Louise Inn, or Lake Louise Campground and can walk to Samson Mall for pickup due to parking restrictions in the town of Lake Louise.';
|
|
83
83
|
|
|
84
84
|
// Filter definitions
|
|
85
85
|
const FILTERS = [
|
|
@@ -250,11 +250,9 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
250
250
|
const [isGeocoding, setIsGeocoding] = useState(false);
|
|
251
251
|
const [selectedMarker, setSelectedMarker] = useState<string | null>(null);
|
|
252
252
|
const [hoveredMarker, setHoveredMarker] = useState<string | null>(null);
|
|
253
|
-
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<AutocompleteSuggestion[]>([]);
|
|
254
253
|
const [exactMatchLocationId, setExactMatchLocationId] = useState<string | null>(null);
|
|
255
254
|
const [cityFilter, setCityFilter] = useState<string | null>(null);
|
|
256
255
|
const [selectedFilters, setSelectedFilters] = useState<Set<string>>(new Set());
|
|
257
|
-
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
258
256
|
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
|
|
259
257
|
const [showSkipWarning, setShowSkipWarning] = useState(false);
|
|
260
258
|
const [dontKnowFilterActive, setDontKnowFilterActive] = useState(false);
|
|
@@ -286,6 +284,20 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
286
284
|
const { googleMapsApiKey: keyFromContext } = useBookingApp();
|
|
287
285
|
const keyFromWindow = typeof window !== 'undefined' ? (window as unknown as { __TICKETBOOTH_GOOGLE_MAPS_API_KEY__?: string }).__TICKETBOOTH_GOOGLE_MAPS_API_KEY__ : undefined;
|
|
288
286
|
const googleMapsApiKey = keyFromContext ?? keyFromWindow ?? env.GOOGLE_MAPS_API_KEY;
|
|
287
|
+
const {
|
|
288
|
+
suggestions: autocompleteSuggestions,
|
|
289
|
+
showSuggestions,
|
|
290
|
+
setShowSuggestions,
|
|
291
|
+
hideSuggestions,
|
|
292
|
+
clearSuggestions,
|
|
293
|
+
queueSuggestionsFetch,
|
|
294
|
+
consumeSessionToken,
|
|
295
|
+
endSession,
|
|
296
|
+
cancelPendingFetch,
|
|
297
|
+
} = usePlacesAutocompleteSearch({
|
|
298
|
+
apiKey: googleMapsApiKey,
|
|
299
|
+
locationBias: LOCATION_BIAS,
|
|
300
|
+
});
|
|
289
301
|
|
|
290
302
|
// ============ Google Maps API Loading ============
|
|
291
303
|
const { isLoaded, loadError } = useJsApiLoader({
|
|
@@ -391,14 +403,13 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
391
403
|
setSearchedLocation(null);
|
|
392
404
|
setIsValidLocation(null);
|
|
393
405
|
setNearbyLocations([]);
|
|
394
|
-
|
|
395
|
-
setAutocompleteSuggestions([]);
|
|
406
|
+
clearSuggestions();
|
|
396
407
|
setSelectedMarker(null);
|
|
397
408
|
setExactMatchLocationId(null);
|
|
398
409
|
setCityFilter(null);
|
|
399
410
|
setHoveredMarker(null);
|
|
400
411
|
}
|
|
401
|
-
}, [isSkipped]);
|
|
412
|
+
}, [clearSuggestions, isSkipped]);
|
|
402
413
|
|
|
403
414
|
// Clear search state when a location is selected from the list (parent updates selectedLocationId/selectedCustomAddress)
|
|
404
415
|
// Only run when selection changes - NOT when user types (searchInput), otherwise typing gets erased
|
|
@@ -408,10 +419,9 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
408
419
|
setSearchedLocation(null);
|
|
409
420
|
setIsValidLocation(null);
|
|
410
421
|
setNearbyLocations([]);
|
|
411
|
-
|
|
412
|
-
setAutocompleteSuggestions([]);
|
|
422
|
+
clearSuggestions();
|
|
413
423
|
}
|
|
414
|
-
}, [selectedLocationId, selectedCustomAddress]);
|
|
424
|
+
}, [clearSuggestions, selectedLocationId, selectedCustomAddress]);
|
|
415
425
|
|
|
416
426
|
// Cleanup timeouts on unmount
|
|
417
427
|
useEffect(() => {
|
|
@@ -427,32 +437,15 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
427
437
|
|
|
428
438
|
// ============ Event Handlers ============
|
|
429
439
|
|
|
430
|
-
|
|
431
|
-
const handleSearchInputChange = async (value: string) => {
|
|
440
|
+
const handleSearchInputChange = (value: string) => {
|
|
432
441
|
setSearchInput(value);
|
|
433
|
-
|
|
434
|
-
if (!value.trim() || !googleMapsApiKey) {
|
|
435
|
-
setAutocompleteSuggestions([]);
|
|
436
|
-
setShowSuggestions(false);
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
try {
|
|
441
|
-
const suggestions = await getAutocompleteSuggestions(value, googleMapsApiKey, LOCATION_BIAS);
|
|
442
|
-
// Only update if the input value hasn't changed (avoid race conditions)
|
|
443
|
-
setAutocompleteSuggestions(suggestions);
|
|
444
|
-
setShowSuggestions(suggestions.length > 0);
|
|
445
|
-
} catch (error) {
|
|
446
|
-
console.error('Error fetching autocomplete suggestions:', error);
|
|
447
|
-
setAutocompleteSuggestions([]);
|
|
448
|
-
setShowSuggestions(false);
|
|
449
|
-
}
|
|
442
|
+
queueSuggestionsFetch(value);
|
|
450
443
|
};
|
|
451
444
|
|
|
452
445
|
const handleSuggestionSelect = async (suggestion: AutocompleteSuggestion) => {
|
|
446
|
+
cancelPendingFetch();
|
|
453
447
|
setSearchInput(suggestion.description);
|
|
454
|
-
|
|
455
|
-
setAutocompleteSuggestions([]);
|
|
448
|
+
hideSuggestions();
|
|
456
449
|
setExactMatchLocationId(null);
|
|
457
450
|
setCityFilter(null);
|
|
458
451
|
|
|
@@ -475,15 +468,17 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
475
468
|
setExactMatchLocationId(matchingPickupLocation.id);
|
|
476
469
|
setCityFilter(null); // Clear city filter if it was set
|
|
477
470
|
setSearchedLocation(null); // Don't show searched location pin
|
|
471
|
+
endSession();
|
|
478
472
|
return;
|
|
479
473
|
}
|
|
480
474
|
}
|
|
481
475
|
|
|
482
476
|
// Case 2 & 3: Get place details to determine if it's a city or specific location
|
|
483
477
|
setIsGeocoding(true);
|
|
478
|
+
const sessionToken = consumeSessionToken();
|
|
484
479
|
try {
|
|
485
480
|
// Get place details including types to determine if it's a city
|
|
486
|
-
const placeDetails = await getPlaceDetails(suggestion.placeId, googleMapsApiKey);
|
|
481
|
+
const placeDetails = await getPlaceDetails(suggestion.placeId, googleMapsApiKey, sessionToken);
|
|
487
482
|
|
|
488
483
|
if (placeDetails) {
|
|
489
484
|
const { lat, lng, types } = placeDetails;
|
|
@@ -513,7 +508,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
513
508
|
}
|
|
514
509
|
}
|
|
515
510
|
|
|
516
|
-
// Case 3: It's a specific location -
|
|
511
|
+
// Case 3: It's a specific location - show 3 closest
|
|
517
512
|
setExactMatchLocationId(null);
|
|
518
513
|
setCityFilter(null);
|
|
519
514
|
setSearchedLocation({
|
|
@@ -569,13 +564,15 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
569
564
|
setSearchedLocation(null);
|
|
570
565
|
setIsValidLocation(null);
|
|
571
566
|
setNearbyLocations([]);
|
|
572
|
-
|
|
567
|
+
clearSuggestions();
|
|
573
568
|
setExactMatchLocationId(null);
|
|
574
569
|
setCityFilter(null);
|
|
575
570
|
return;
|
|
576
571
|
}
|
|
577
572
|
|
|
578
|
-
|
|
573
|
+
cancelPendingFetch();
|
|
574
|
+
hideSuggestions();
|
|
575
|
+
endSession();
|
|
579
576
|
setExactMatchLocationId(null);
|
|
580
577
|
setCityFilter(null);
|
|
581
578
|
|
|
@@ -667,7 +664,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
667
664
|
setSearchedLocation(null);
|
|
668
665
|
setIsValidLocation(null);
|
|
669
666
|
setNearbyLocations([]);
|
|
670
|
-
|
|
667
|
+
clearSuggestions();
|
|
671
668
|
setSelectedMarker(null);
|
|
672
669
|
setHoveredMarker(null);
|
|
673
670
|
};
|
|
@@ -710,8 +707,7 @@ function PickupLocationSelectorWithMap(props: PickupLocationSelectorProps) {
|
|
|
710
707
|
setSearchedLocation(null);
|
|
711
708
|
setIsValidLocation(null);
|
|
712
709
|
setNearbyLocations([]);
|
|
713
|
-
|
|
714
|
-
setAutocompleteSuggestions([]);
|
|
710
|
+
clearSuggestions();
|
|
715
711
|
setSelectedMarker(null);
|
|
716
712
|
setHoveredMarker(null);
|
|
717
713
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
createPlacesSessionToken,
|
|
6
|
+
getAutocompleteSuggestions,
|
|
7
|
+
type AutocompleteSuggestion,
|
|
8
|
+
type PlacesLocationBias,
|
|
9
|
+
} from '../lib/booking/places-api';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DEBOUNCE_MS = 300;
|
|
12
|
+
|
|
13
|
+
interface UsePlacesAutocompleteSearchOptions {
|
|
14
|
+
apiKey: string;
|
|
15
|
+
locationBias?: PlacesLocationBias;
|
|
16
|
+
debounceMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function usePlacesAutocompleteSearch({
|
|
20
|
+
apiKey,
|
|
21
|
+
locationBias,
|
|
22
|
+
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
23
|
+
}: UsePlacesAutocompleteSearchOptions) {
|
|
24
|
+
const [suggestions, setSuggestions] = useState<AutocompleteSuggestion[]>([]);
|
|
25
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
26
|
+
const sessionTokenRef = useRef<string | null>(null);
|
|
27
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
28
|
+
const requestIdRef = useRef(0);
|
|
29
|
+
|
|
30
|
+
const cancelPendingFetch = useCallback(() => {
|
|
31
|
+
if (debounceRef.current) {
|
|
32
|
+
clearTimeout(debounceRef.current);
|
|
33
|
+
debounceRef.current = null;
|
|
34
|
+
}
|
|
35
|
+
requestIdRef.current += 1;
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const hideSuggestions = useCallback(() => {
|
|
39
|
+
setSuggestions([]);
|
|
40
|
+
setShowSuggestions(false);
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const endSession = useCallback(() => {
|
|
44
|
+
sessionTokenRef.current = null;
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const clearSuggestions = useCallback(() => {
|
|
48
|
+
cancelPendingFetch();
|
|
49
|
+
hideSuggestions();
|
|
50
|
+
endSession();
|
|
51
|
+
}, [cancelPendingFetch, hideSuggestions, endSession]);
|
|
52
|
+
|
|
53
|
+
const consumeSessionToken = useCallback((): string | undefined => {
|
|
54
|
+
const token = sessionTokenRef.current;
|
|
55
|
+
sessionTokenRef.current = null;
|
|
56
|
+
return token ?? undefined;
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const ensureSessionToken = useCallback(() => {
|
|
60
|
+
if (!sessionTokenRef.current) {
|
|
61
|
+
sessionTokenRef.current = createPlacesSessionToken();
|
|
62
|
+
}
|
|
63
|
+
return sessionTokenRef.current;
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const fetchSuggestions = useCallback(
|
|
67
|
+
async (input: string) => {
|
|
68
|
+
const requestId = ++requestIdRef.current;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const sessionToken = ensureSessionToken();
|
|
72
|
+
const nextSuggestions = await getAutocompleteSuggestions(
|
|
73
|
+
input,
|
|
74
|
+
apiKey,
|
|
75
|
+
locationBias,
|
|
76
|
+
sessionToken
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (requestId !== requestIdRef.current) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setSuggestions(nextSuggestions);
|
|
84
|
+
setShowSuggestions(nextSuggestions.length > 0);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (requestId !== requestIdRef.current) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.error('Error fetching autocomplete suggestions:', error);
|
|
91
|
+
setSuggestions([]);
|
|
92
|
+
setShowSuggestions(false);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
[apiKey, ensureSessionToken, locationBias]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const queueSuggestionsFetch = useCallback(
|
|
99
|
+
(input: string) => {
|
|
100
|
+
cancelPendingFetch();
|
|
101
|
+
|
|
102
|
+
if (!input.trim() || !apiKey) {
|
|
103
|
+
hideSuggestions();
|
|
104
|
+
endSession();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
debounceRef.current = setTimeout(() => {
|
|
109
|
+
debounceRef.current = null;
|
|
110
|
+
void fetchSuggestions(input);
|
|
111
|
+
}, debounceMs);
|
|
112
|
+
},
|
|
113
|
+
[apiKey, cancelPendingFetch, debounceMs, endSession, fetchSuggestions, hideSuggestions]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
cancelPendingFetch();
|
|
119
|
+
};
|
|
120
|
+
}, [cancelPendingFetch]);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
suggestions,
|
|
124
|
+
showSuggestions,
|
|
125
|
+
setShowSuggestions,
|
|
126
|
+
hideSuggestions,
|
|
127
|
+
clearSuggestions,
|
|
128
|
+
queueSuggestionsFetch,
|
|
129
|
+
consumeSessionToken,
|
|
130
|
+
endSession,
|
|
131
|
+
cancelPendingFetch,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -9,6 +9,12 @@ export interface AutocompleteSuggestion {
|
|
|
9
9
|
description: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface PlacesLocationBias {
|
|
13
|
+
latitude: number;
|
|
14
|
+
longitude: number;
|
|
15
|
+
radius: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
// Internal API response types
|
|
13
19
|
interface PlacePrediction {
|
|
14
20
|
placeId: string;
|
|
@@ -25,13 +31,22 @@ interface AutocompleteSuggestionResponse {
|
|
|
25
31
|
|
|
26
32
|
const GOOGLE_PLACES_API_BASE = 'https://places.googleapis.com/v1';
|
|
27
33
|
|
|
34
|
+
/** Session tokens group autocomplete keystrokes + place selection into one billing session. */
|
|
35
|
+
export function createPlacesSessionToken(): string {
|
|
36
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
37
|
+
return crypto.randomUUID();
|
|
38
|
+
}
|
|
39
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* Get autocomplete suggestions using Places API (New)
|
|
30
44
|
*/
|
|
31
45
|
export async function getAutocompleteSuggestions(
|
|
32
46
|
input: string,
|
|
33
47
|
apiKey: string,
|
|
34
|
-
locationBias?:
|
|
48
|
+
locationBias?: PlacesLocationBias,
|
|
49
|
+
sessionToken?: string
|
|
35
50
|
): Promise<AutocompleteSuggestion[]> {
|
|
36
51
|
if (!input.trim() || !apiKey) {
|
|
37
52
|
return [];
|
|
@@ -49,6 +64,7 @@ export async function getAutocompleteSuggestions(
|
|
|
49
64
|
body: JSON.stringify({
|
|
50
65
|
input,
|
|
51
66
|
includedRegionCodes: ['ca'], // Restrict to Canada
|
|
67
|
+
...(sessionToken && { sessionToken }),
|
|
52
68
|
...(locationBias && {
|
|
53
69
|
locationBias: {
|
|
54
70
|
circle: {
|
|
@@ -75,7 +91,7 @@ export async function getAutocompleteSuggestions(
|
|
|
75
91
|
|
|
76
92
|
if (data.suggestions && Array.isArray(data.suggestions)) {
|
|
77
93
|
return data.suggestions
|
|
78
|
-
.filter((s): s is AutocompleteSuggestionResponse & { placePrediction: PlacePrediction } =>
|
|
94
|
+
.filter((s): s is AutocompleteSuggestionResponse & { placePrediction: PlacePrediction } =>
|
|
79
95
|
!!s.placePrediction
|
|
80
96
|
)
|
|
81
97
|
.map((s) => {
|
|
@@ -104,10 +120,12 @@ export async function getAutocompleteSuggestions(
|
|
|
104
120
|
*/
|
|
105
121
|
export async function getPlaceDetails(
|
|
106
122
|
placeId: string,
|
|
107
|
-
apiKey: string
|
|
123
|
+
apiKey: string,
|
|
124
|
+
sessionToken?: string
|
|
108
125
|
): Promise<{ lat: number; lng: number; types: string[] } | null> {
|
|
109
126
|
try {
|
|
110
|
-
const
|
|
127
|
+
const sessionQuery = sessionToken ? `?sessionToken=${encodeURIComponent(sessionToken)}` : '';
|
|
128
|
+
const response = await fetch(`${GOOGLE_PLACES_API_BASE}/places/${placeId}${sessionQuery}`, {
|
|
111
129
|
method: 'GET',
|
|
112
130
|
headers: {
|
|
113
131
|
'Content-Type': 'application/json',
|
|
@@ -143,12 +161,12 @@ export async function getPlaceDetails(
|
|
|
143
161
|
*/
|
|
144
162
|
export async function getPlaceCoordinates(
|
|
145
163
|
placeId: string,
|
|
146
|
-
apiKey: string
|
|
164
|
+
apiKey: string,
|
|
165
|
+
sessionToken?: string
|
|
147
166
|
): Promise<{ lat: number; lng: number } | null> {
|
|
148
|
-
const details = await getPlaceDetails(placeId, apiKey);
|
|
167
|
+
const details = await getPlaceDetails(placeId, apiKey, sessionToken);
|
|
149
168
|
if (details) {
|
|
150
169
|
return { lat: details.lat, lng: details.lng };
|
|
151
170
|
}
|
|
152
171
|
return null;
|
|
153
172
|
}
|
|
154
|
-
|
package/src/lib/booking-api.ts
CHANGED
|
@@ -935,7 +935,8 @@ export async function validatePromoCode(
|
|
|
935
935
|
promoCode: string,
|
|
936
936
|
companyId: string,
|
|
937
937
|
productId?: string,
|
|
938
|
-
hasOngoingDiscount?: boolean
|
|
938
|
+
hasOngoingDiscount?: boolean,
|
|
939
|
+
dateTime?: string | null
|
|
939
940
|
): Promise<ValidatePromoResponse> {
|
|
940
941
|
const params = new URLSearchParams({ promoCode: promoCode.trim(), companyId });
|
|
941
942
|
const normalizedProductId = productId?.trim()
|
|
@@ -943,6 +944,7 @@ export async function validatePromoCode(
|
|
|
943
944
|
: '';
|
|
944
945
|
if (normalizedProductId) params.set('productId', normalizedProductId);
|
|
945
946
|
if (hasOngoingDiscount === true) params.set('hasOngoingDiscount', 'true');
|
|
947
|
+
if (dateTime?.trim()) params.set('dateTime', dateTime.trim());
|
|
946
948
|
const res = await fetchBookingGetWithRetry(`${API_BASE}/1/validate-promo?${params}`);
|
|
947
949
|
if (!res.ok) {
|
|
948
950
|
const err = await res.json();
|
|
@@ -964,6 +966,14 @@ export interface GetPromoDiscountBookingChangeOptions {
|
|
|
964
966
|
forBookingChange?: boolean;
|
|
965
967
|
/** Stored promo discount from the existing booking (major units, same currency as quote). */
|
|
966
968
|
priorPromoDiscountAmount?: number;
|
|
969
|
+
/** Live available capacity for the selected slot, used by dynamic pricing. */
|
|
970
|
+
availabilityCount?: number;
|
|
971
|
+
/** Live total capacity for the selected slot, used by dynamic pricing. */
|
|
972
|
+
totalCapacity?: number;
|
|
973
|
+
/** Displayed ticket line totals from the cart, used to value vouchers against the visible price. */
|
|
974
|
+
displayedTicketLines?: Array<{ category: string; qty: number; itemTotal: number }>;
|
|
975
|
+
/** Displayed product fee total from the cart, excluding add-ons, return, and cancellation lines. */
|
|
976
|
+
displayedProductFeeTotal?: number;
|
|
967
977
|
}
|
|
968
978
|
|
|
969
979
|
export async function getPromoDiscount(
|
|
@@ -988,6 +998,32 @@ export async function getPromoDiscount(
|
|
|
988
998
|
});
|
|
989
999
|
if (dateTime) params.set('dateTime', dateTime);
|
|
990
1000
|
if (subtotal != null && subtotal > 0) params.set('subtotal', String(subtotal));
|
|
1001
|
+
if (
|
|
1002
|
+
bookingChange?.availabilityCount != null &&
|
|
1003
|
+
Number.isFinite(bookingChange.availabilityCount)
|
|
1004
|
+
) {
|
|
1005
|
+
params.set('availabilityCount', String(Math.trunc(bookingChange.availabilityCount)));
|
|
1006
|
+
}
|
|
1007
|
+
if (
|
|
1008
|
+
bookingChange?.totalCapacity != null &&
|
|
1009
|
+
Number.isFinite(bookingChange.totalCapacity)
|
|
1010
|
+
) {
|
|
1011
|
+
params.set('totalCapacity', String(Math.trunc(bookingChange.totalCapacity)));
|
|
1012
|
+
}
|
|
1013
|
+
const displayedTicketLines = bookingChange?.displayedTicketLines
|
|
1014
|
+
?.filter((line) => line.qty > 0 && Number.isFinite(line.itemTotal) && line.itemTotal >= 0)
|
|
1015
|
+
.map((line) => `${line.category}:${Math.trunc(line.qty)}:${line.itemTotal}`)
|
|
1016
|
+
.join(',');
|
|
1017
|
+
if (displayedTicketLines) {
|
|
1018
|
+
params.set('ticketLines', displayedTicketLines);
|
|
1019
|
+
}
|
|
1020
|
+
if (
|
|
1021
|
+
bookingChange?.displayedProductFeeTotal != null &&
|
|
1022
|
+
Number.isFinite(bookingChange.displayedProductFeeTotal) &&
|
|
1023
|
+
bookingChange.displayedProductFeeTotal >= 0
|
|
1024
|
+
) {
|
|
1025
|
+
params.set('productFeeTotal', String(bookingChange.displayedProductFeeTotal));
|
|
1026
|
+
}
|
|
991
1027
|
if (bookingChange?.forBookingChange === true) {
|
|
992
1028
|
params.set('forBookingChange', 'true');
|
|
993
1029
|
}
|