@ticketboothapp/booking 1.2.112 → 1.2.114
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/Calendar.module.css +58 -13
- package/src/components/booking/Calendar.tsx +20 -6
- 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/components/booking/PrivateShuttleBookingFlow.tsx +124 -4
- package/src/data/products-config.json +1 -0
- 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.114",
|
|
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",
|
|
@@ -76,8 +76,10 @@
|
|
|
76
76
|
--cal-discount-tag-bg: rgba(239, 68, 68, 0.9);
|
|
77
77
|
--cal-discount-tag-text: #fff;
|
|
78
78
|
|
|
79
|
-
/* Today
|
|
80
|
-
--cal-today-ring: var(--booking-
|
|
79
|
+
/* Today marker */
|
|
80
|
+
--cal-today-ring: var(--booking-orange-600, #ff4d00);
|
|
81
|
+
--cal-today-text: var(--booking-orange-600, #ff4d00);
|
|
82
|
+
--cal-today-dot: var(--booking-orange-600, #ff4d00);
|
|
81
83
|
|
|
82
84
|
/* Grid borders */
|
|
83
85
|
--cal-grid-border: var(--booking-stone-300, #d6d3d1);
|
|
@@ -201,7 +203,8 @@
|
|
|
201
203
|
--cal-dropdown-day-selected-bg-hover: var(--booking-emerald-700, #047857);
|
|
202
204
|
--cal-dropdown-day-soldout-bg: #f87171;
|
|
203
205
|
--cal-dropdown-day-soldout-bg-hover: #ef4444;
|
|
204
|
-
--cal-dropdown-day-today-ring: var(--booking-
|
|
206
|
+
--cal-dropdown-day-today-ring: var(--booking-orange-600, #ff4d00);
|
|
207
|
+
--cal-dropdown-day-today-text: var(--booking-orange-600, #ff4d00);
|
|
205
208
|
|
|
206
209
|
position: fixed;
|
|
207
210
|
background-color: var(--cal-dropdown-bg);
|
|
@@ -305,6 +308,7 @@
|
|
|
305
308
|
transition: background-color 0.2s, color 0.2s;
|
|
306
309
|
border: none;
|
|
307
310
|
cursor: pointer;
|
|
311
|
+
position: relative;
|
|
308
312
|
}
|
|
309
313
|
|
|
310
314
|
.calendarDropdownDayMuted {
|
|
@@ -312,6 +316,12 @@
|
|
|
312
316
|
cursor: not-allowed;
|
|
313
317
|
}
|
|
314
318
|
|
|
319
|
+
.calendarDropdownDayPast {
|
|
320
|
+
background-color: var(--booking-stone-100, #f5f5f4);
|
|
321
|
+
color: var(--booking-stone-400, #a8a29e);
|
|
322
|
+
cursor: not-allowed;
|
|
323
|
+
}
|
|
324
|
+
|
|
315
325
|
.calendarDropdownDayAvailable {
|
|
316
326
|
color: var(--cal-dropdown-day-text);
|
|
317
327
|
background-color: var(--cal-dropdown-day-available-bg);
|
|
@@ -348,6 +358,20 @@
|
|
|
348
358
|
|
|
349
359
|
.calendarDropdownDayToday {
|
|
350
360
|
box-shadow: inset 0 0 0 1px var(--cal-dropdown-day-today-ring);
|
|
361
|
+
color: var(--cal-dropdown-day-today-text);
|
|
362
|
+
font-weight: 700;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.calendarDropdownDayToday::after {
|
|
366
|
+
content: "";
|
|
367
|
+
position: absolute;
|
|
368
|
+
left: 50%;
|
|
369
|
+
bottom: 0.1875rem;
|
|
370
|
+
width: 0.25rem;
|
|
371
|
+
height: 0.25rem;
|
|
372
|
+
border-radius: 999px;
|
|
373
|
+
background-color: var(--cal-dropdown-day-today-ring);
|
|
374
|
+
transform: translateX(-50%);
|
|
351
375
|
}
|
|
352
376
|
|
|
353
377
|
.calendarGrid {
|
|
@@ -402,10 +426,10 @@
|
|
|
402
426
|
}
|
|
403
427
|
}
|
|
404
428
|
|
|
405
|
-
/* Desktop:
|
|
429
|
+
/* Desktop: stretch each day to the row height so grid borders stay aligned across empty and available days. */
|
|
406
430
|
@media (min-width: 640px) {
|
|
407
431
|
.calendarDaysGrid {
|
|
408
|
-
align-items:
|
|
432
|
+
align-items: stretch;
|
|
409
433
|
}
|
|
410
434
|
|
|
411
435
|
/* Admin capacity mode: keep all days in a row equal height (including empty days like leading/trailing month dates). */
|
|
@@ -456,15 +480,9 @@
|
|
|
456
480
|
width: 100%;
|
|
457
481
|
}
|
|
458
482
|
|
|
459
|
-
/* Desktop:
|
|
483
|
+
/* Desktop: center availability content while row stretching keeps grid borders aligned. */
|
|
460
484
|
@media (min-width: 640px) {
|
|
461
485
|
.calendarDayCellInner:not(.calendarDayCellInnerMobile) {
|
|
462
|
-
justify-content: flex-end;
|
|
463
|
-
padding-bottom: 0.125rem;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/* Admin capacity mode: with stretched row heights, bottom-pinning creates excessive top whitespace. */
|
|
467
|
-
.calendar.calendarAdminMode .calendarDayCellInner:not(.calendarDayCellInnerMobile) {
|
|
468
486
|
justify-content: center;
|
|
469
487
|
padding-bottom: 0;
|
|
470
488
|
}
|
|
@@ -517,8 +535,19 @@
|
|
|
517
535
|
cursor: not-allowed;
|
|
518
536
|
}
|
|
519
537
|
|
|
538
|
+
.calendar button.calendarDayCellPast,
|
|
539
|
+
.calendar .calendarDayCellPast {
|
|
540
|
+
background-color: var(--booking-stone-100, #f5f5f4);
|
|
541
|
+
color: var(--booking-stone-400, #a8a29e);
|
|
542
|
+
cursor: not-allowed;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.calendarDayCellPast .calendarDayNumber {
|
|
546
|
+
color: var(--booking-stone-400, #a8a29e);
|
|
547
|
+
}
|
|
548
|
+
|
|
520
549
|
.calendarDayCellToday {
|
|
521
|
-
box-shadow:
|
|
550
|
+
box-shadow: none;
|
|
522
551
|
}
|
|
523
552
|
|
|
524
553
|
/* Day number */
|
|
@@ -534,6 +563,22 @@
|
|
|
534
563
|
color: var(--cal-day-number-selected);
|
|
535
564
|
}
|
|
536
565
|
|
|
566
|
+
.calendarDayCellToday .calendarDayNumber {
|
|
567
|
+
color: var(--cal-today-text);
|
|
568
|
+
font-weight: 700;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.calendarDayCellToday .calendarDayNumber::after {
|
|
572
|
+
content: "";
|
|
573
|
+
display: inline-block;
|
|
574
|
+
width: 0.25rem;
|
|
575
|
+
height: 0.25rem;
|
|
576
|
+
margin-left: 0.1875rem;
|
|
577
|
+
border-radius: 999px;
|
|
578
|
+
background-color: var(--cal-today-dot);
|
|
579
|
+
vertical-align: 0.125rem;
|
|
580
|
+
}
|
|
581
|
+
|
|
537
582
|
/* ============ BADGES & PILLS ============ */
|
|
538
583
|
.calendarDiscountTag {
|
|
539
584
|
position: absolute;
|
|
@@ -178,6 +178,7 @@ interface DateCellProps {
|
|
|
178
178
|
availability: DateAvailability | null;
|
|
179
179
|
isSelected: boolean;
|
|
180
180
|
isToday: boolean;
|
|
181
|
+
isPast: boolean;
|
|
181
182
|
showCapacity?: boolean;
|
|
182
183
|
timezone: string;
|
|
183
184
|
displayMode: 'times' | 'status';
|
|
@@ -192,6 +193,7 @@ const DateCell = memo(function DateCell({
|
|
|
192
193
|
availability,
|
|
193
194
|
isSelected,
|
|
194
195
|
isToday,
|
|
196
|
+
isPast,
|
|
195
197
|
showCapacity,
|
|
196
198
|
timezone,
|
|
197
199
|
displayMode,
|
|
@@ -209,7 +211,7 @@ const DateCell = memo(function DateCell({
|
|
|
209
211
|
// In change flow, also allow the original booked sold-out date to stay selectable.
|
|
210
212
|
const hasAvailability =
|
|
211
213
|
hasAvailabilityData && (!availability.isSoldOut || showCapacity || allowPinnedSoldOutDate);
|
|
212
|
-
const isDisabled = !hasAvailability;
|
|
214
|
+
const isDisabled = isPast || !hasAvailability;
|
|
213
215
|
|
|
214
216
|
// Format time for display. timeStr (e.g. "09:00") is already in company timezone (MDT).
|
|
215
217
|
// Create a Date with those components, interpret as company timezone, then format.
|
|
@@ -246,21 +248,24 @@ const DateCell = memo(function DateCell({
|
|
|
246
248
|
? 'min-w-0 min-h-[5.75rem]'
|
|
247
249
|
: 'min-h-[5rem]'
|
|
248
250
|
),
|
|
249
|
-
|
|
251
|
+
isPast
|
|
252
|
+
? styles.calendarDayCellPast
|
|
253
|
+
: isDisabled
|
|
250
254
|
? styles.calendarDayCellDisabled
|
|
251
255
|
: isSelected
|
|
252
256
|
? styles.calendarDayCellSelected
|
|
253
257
|
: styles.calendarDayCellAvailable,
|
|
254
|
-
isToday &&
|
|
255
|
-
), [isDisabled, isSelected, isToday,
|
|
258
|
+
isToday && !isPast && !isSelected && styles.calendarDayCellToday
|
|
259
|
+
), [isPast, isDisabled, isSelected, isToday, needsTallerCell, isMobile, mobileNeedsTaller, adminCapacityTallCells]);
|
|
256
260
|
|
|
257
261
|
const ariaLabel = useMemo(() => {
|
|
258
262
|
const parts = [dayNumber];
|
|
263
|
+
if (isPast) parts.push(' - Past date');
|
|
259
264
|
if (availability?.isSoldOut) parts.push(' - Sold out');
|
|
260
265
|
const pct = availability?.totalDiscountPercent;
|
|
261
266
|
if (pct != null && pct > 0) parts.push(` - ${pct}% off`);
|
|
262
267
|
return parts.join('');
|
|
263
|
-
}, [dayNumber, availability?.isSoldOut, availability?.totalDiscountPercent]);
|
|
268
|
+
}, [dayNumber, isPast, availability?.isSoldOut, availability?.totalDiscountPercent]);
|
|
264
269
|
|
|
265
270
|
return (
|
|
266
271
|
<button
|
|
@@ -996,6 +1001,8 @@ export function Calendar({
|
|
|
996
1001
|
getEffectiveVacancies,
|
|
997
1002
|
]);
|
|
998
1003
|
|
|
1004
|
+
const todayDateStr = formatInTimeZone(new Date(), timezone, 'yyyy-MM-dd');
|
|
1005
|
+
|
|
999
1006
|
// Get display range for header
|
|
1000
1007
|
const displayRange = useMemo(() => {
|
|
1001
1008
|
const firstDay = calendarDays[0];
|
|
@@ -1022,6 +1029,8 @@ export function Calendar({
|
|
|
1022
1029
|
|
|
1023
1030
|
const handleDateClick = (date: Date) => {
|
|
1024
1031
|
const dateStr = formatInTimeZone(date, timezone, 'yyyy-MM-dd');
|
|
1032
|
+
if (dateStr < todayDateStr) return;
|
|
1033
|
+
|
|
1025
1034
|
const availability = dateAvailabilityMap[dateStr] || null;
|
|
1026
1035
|
|
|
1027
1036
|
const allowPinnedSoldOutDate = Boolean(
|
|
@@ -1165,6 +1174,7 @@ export function Calendar({
|
|
|
1165
1174
|
const availability = dateAvailabilityMap[dateStr] || null;
|
|
1166
1175
|
const isSelected = selectedDate === dateStr;
|
|
1167
1176
|
const isCurrentMonth = day >= miniCalendarData.monthStart && day <= miniCalendarData.monthEnd;
|
|
1177
|
+
const isPast = dateStr < todayDateStr;
|
|
1168
1178
|
// Check if day is within allowed range (June 1 - October 12, 2026) in company timezone
|
|
1169
1179
|
const isInAllowedRange = day >= miniCalendarBoundsInTz.start && day <= miniCalendarBoundsInTz.end;
|
|
1170
1180
|
|
|
@@ -1178,19 +1188,21 @@ export function Calendar({
|
|
|
1178
1188
|
const isAvailable =
|
|
1179
1189
|
hasAvailabilityData &&
|
|
1180
1190
|
isInAllowedRange &&
|
|
1191
|
+
!isPast &&
|
|
1181
1192
|
(!isSoldOut || showCapacity || allowPinnedSoldOutDate);
|
|
1182
1193
|
const isToday = isSameDay(day, new Date());
|
|
1183
1194
|
|
|
1184
1195
|
const dayClass = cn(
|
|
1185
1196
|
styles.calendarDropdownDay,
|
|
1186
1197
|
!isCurrentMonth && styles.calendarDropdownDayMuted,
|
|
1198
|
+
isPast && styles.calendarDropdownDayPast,
|
|
1187
1199
|
isSoldOut && !showCapacity && styles.calendarDropdownDaySoldOut,
|
|
1188
1200
|
isSoldOut && showCapacity && styles.calendarDropdownDaySoldOutAdmin,
|
|
1189
1201
|
!isSoldOut && !isInAllowedRange && styles.calendarDropdownDayMuted,
|
|
1190
1202
|
!hasAvailabilityData && isInAllowedRange && styles.calendarDropdownDayMuted,
|
|
1191
1203
|
isSelected && isAvailable && styles.calendarDropdownDaySelected,
|
|
1192
1204
|
isAvailable && !isSelected && styles.calendarDropdownDayAvailable,
|
|
1193
|
-
isToday &&
|
|
1205
|
+
isToday && !isPast && !isSelected && styles.calendarDropdownDayToday
|
|
1194
1206
|
);
|
|
1195
1207
|
|
|
1196
1208
|
return (
|
|
@@ -1276,6 +1288,7 @@ export function Calendar({
|
|
|
1276
1288
|
const availability = dateAvailabilityMap[dateStr] || null;
|
|
1277
1289
|
const isSelected = selectedDate === dateStr;
|
|
1278
1290
|
const isToday = isSameDay(date, new Date());
|
|
1291
|
+
const isPast = dateStr < todayDateStr;
|
|
1279
1292
|
|
|
1280
1293
|
// Include availability data in key to force re-render when availability changes
|
|
1281
1294
|
const availabilityKey = availability
|
|
@@ -1289,6 +1302,7 @@ export function Calendar({
|
|
|
1289
1302
|
availability={availability}
|
|
1290
1303
|
isSelected={isSelected}
|
|
1291
1304
|
isToday={isToday}
|
|
1305
|
+
isPast={isPast}
|
|
1292
1306
|
showCapacity={showCapacity}
|
|
1293
1307
|
timezone={timezone}
|
|
1294
1308
|
displayMode={displayMode}
|
|
@@ -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
|
|
|
@@ -33,6 +33,10 @@ import {
|
|
|
33
33
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
34
34
|
import { formatBookingRefForDisplay } from '../../lib/booking-ref';
|
|
35
35
|
import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
|
|
36
|
+
import {
|
|
37
|
+
isDateAfterPublicBookingCutoff,
|
|
38
|
+
resolvePublicBookingCutoffMinutes,
|
|
39
|
+
} from '../../lib/booking/booking-cutoffs';
|
|
36
40
|
import type { PricingConfig, PrecomputedPricesByCategory } from '../../lib/booking-api';
|
|
37
41
|
import { Calendar } from './Calendar';
|
|
38
42
|
import { PickupLocationSelector } from './PickupLocationSelector';
|
|
@@ -122,6 +126,32 @@ function parseAvailabilityDateTime(value: string): Date {
|
|
|
122
126
|
return parseISO(hasExplicitOffset ? value : `${value}Z`);
|
|
123
127
|
}
|
|
124
128
|
|
|
129
|
+
function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
|
|
130
|
+
return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function privateShuttleStartTimeAllowed(
|
|
134
|
+
dateStr: string,
|
|
135
|
+
timeStr: string,
|
|
136
|
+
timezone: string,
|
|
137
|
+
now: Date,
|
|
138
|
+
cutoffMinutes?: number | null,
|
|
139
|
+
): boolean {
|
|
140
|
+
return isDateAfterPublicBookingCutoff(
|
|
141
|
+
privateShuttleStartDateTime(dateStr, timeStr, timezone),
|
|
142
|
+
now,
|
|
143
|
+
cutoffMinutes,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function privateShuttleBookingCutoffMessage(cutoffMinutes?: number | null): string {
|
|
148
|
+
const resolvedCutoffMinutes = resolvePublicBookingCutoffMinutes(cutoffMinutes);
|
|
149
|
+
if (resolvedCutoffMinutes % 60 === 0) {
|
|
150
|
+
return `Private shuttles must be booked at least ${resolvedCutoffMinutes / 60} hours before departure.`;
|
|
151
|
+
}
|
|
152
|
+
return `Private shuttles must be booked at least ${resolvedCutoffMinutes} minutes before departure.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
125
155
|
function findMergedPrivateShuttleAvailability(
|
|
126
156
|
merged: Availability[],
|
|
127
157
|
sel: Availability | null,
|
|
@@ -257,6 +287,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
257
287
|
const [loading, setLoading] = useState(false);
|
|
258
288
|
const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
|
|
259
289
|
const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
|
|
290
|
+
const [bookingCutoffNowMs, setBookingCutoffNowMs] = useState(() => Date.now());
|
|
260
291
|
const [error, setError] = useState('');
|
|
261
292
|
const [showCheckoutModal, setShowCheckoutModal] = useState(false);
|
|
262
293
|
/** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
|
|
@@ -355,6 +386,14 @@ export function PrivateShuttleBookingFlow({
|
|
|
355
386
|
() => product.options?.filter((opt) => opt.status === 'ACTIVE') || [],
|
|
356
387
|
[product.options]
|
|
357
388
|
);
|
|
389
|
+
const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
|
|
390
|
+
const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
|
|
391
|
+
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
if (isAdmin) return;
|
|
394
|
+
const interval = window.setInterval(() => setBookingCutoffNowMs(Date.now()), 60 * 1000);
|
|
395
|
+
return () => window.clearInterval(interval);
|
|
396
|
+
}, [isAdmin]);
|
|
358
397
|
|
|
359
398
|
// Fire view_item when product is first displayed
|
|
360
399
|
const hasFiredViewItem = useRef(false);
|
|
@@ -780,9 +819,43 @@ export function PrivateShuttleBookingFlow({
|
|
|
780
819
|
availabilitiesCache,
|
|
781
820
|
]);
|
|
782
821
|
|
|
822
|
+
const isPrivateShuttleAvailabilityBookable = useCallback(
|
|
823
|
+
(availability: Availability): boolean => {
|
|
824
|
+
if (isAdmin) return true;
|
|
825
|
+
const dateInCompanyTz = DATE_ONLY_REGEX.test(availability.dateTime)
|
|
826
|
+
? availability.dateTime
|
|
827
|
+
: formatInTimeZone(parseAvailabilityDateTime(availability.dateTime), companyTimezone, 'yyyy-MM-dd');
|
|
828
|
+
const optionId = getPrivateShuttleAvailabilityOptionId(availability);
|
|
829
|
+
const option = activeOptions.find((opt) => opt.optionId === optionId);
|
|
830
|
+
const startTimes = availability.suggestedStartTimes || option?.privateShuttleConfig?.suggestedStartTimes || [];
|
|
831
|
+
if (startTimes.length === 0) {
|
|
832
|
+
return isDateAfterPublicBookingCutoff(
|
|
833
|
+
fromZonedTime(parseISO(`${dateInCompanyTz}T23:59:59`), companyTimezone),
|
|
834
|
+
bookingCutoffNow,
|
|
835
|
+
bookingCutoffMinutes,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
return startTimes.some((time) =>
|
|
839
|
+
privateShuttleStartTimeAllowed(
|
|
840
|
+
dateInCompanyTz,
|
|
841
|
+
time,
|
|
842
|
+
companyTimezone,
|
|
843
|
+
bookingCutoffNow,
|
|
844
|
+
bookingCutoffMinutes,
|
|
845
|
+
),
|
|
846
|
+
);
|
|
847
|
+
},
|
|
848
|
+
[activeOptions, bookingCutoffMinutes, bookingCutoffNow, companyTimezone, isAdmin],
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
const publicVisibleAvailabilities = useMemo(
|
|
852
|
+
() => (isAdmin ? availabilities : availabilities.filter(isPrivateShuttleAvailabilityBookable)),
|
|
853
|
+
[availabilities, isAdmin, isPrivateShuttleAvailabilityBookable],
|
|
854
|
+
);
|
|
855
|
+
|
|
783
856
|
// Group availabilities by date (in company timezone - MDT). Availabilities from API are in UTC.
|
|
784
857
|
const availabilitiesByDate = useMemo(() => {
|
|
785
|
-
const grouped =
|
|
858
|
+
const grouped = publicVisibleAvailabilities.reduce(
|
|
786
859
|
(acc, avail) => {
|
|
787
860
|
const dateInCompanyTz = DATE_ONLY_REGEX.test(avail.dateTime)
|
|
788
861
|
? avail.dateTime
|
|
@@ -803,7 +876,7 @@ export function PrivateShuttleBookingFlow({
|
|
|
803
876
|
});
|
|
804
877
|
});
|
|
805
878
|
return sorted;
|
|
806
|
-
}, [
|
|
879
|
+
}, [publicVisibleAvailabilities, companyTimezone]);
|
|
807
880
|
|
|
808
881
|
const optionsAvailableForSelectedDate = useMemo(() => {
|
|
809
882
|
if (!selectedDate) return [];
|
|
@@ -914,8 +987,27 @@ export function PrivateShuttleBookingFlow({
|
|
|
914
987
|
return fromZonedTime(parseISO(`${dates[0]}T12:00:00`), companyTimezone);
|
|
915
988
|
}, [dates, companyTimezone]);
|
|
916
989
|
|
|
917
|
-
const suggestedStartTimes =
|
|
918
|
-
selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
|
|
990
|
+
const suggestedStartTimes = useMemo(() => {
|
|
991
|
+
const times = selectedAvailability?.suggestedStartTimes || privateShuttleConfig?.suggestedStartTimes || [];
|
|
992
|
+
if (isAdmin || !selectedDate) return times;
|
|
993
|
+
return times.filter((time) =>
|
|
994
|
+
privateShuttleStartTimeAllowed(
|
|
995
|
+
selectedDate,
|
|
996
|
+
time,
|
|
997
|
+
companyTimezone,
|
|
998
|
+
bookingCutoffNow,
|
|
999
|
+
bookingCutoffMinutes,
|
|
1000
|
+
),
|
|
1001
|
+
);
|
|
1002
|
+
}, [
|
|
1003
|
+
bookingCutoffMinutes,
|
|
1004
|
+
bookingCutoffNow,
|
|
1005
|
+
companyTimezone,
|
|
1006
|
+
isAdmin,
|
|
1007
|
+
privateShuttleConfig?.suggestedStartTimes,
|
|
1008
|
+
selectedAvailability?.suggestedStartTimes,
|
|
1009
|
+
selectedDate,
|
|
1010
|
+
]);
|
|
919
1011
|
const resourceCount = Math.ceil(passengerCount / RESOURCE_CAPACITY);
|
|
920
1012
|
const maxVacancies = selectedAvailability?.vacancies || 0;
|
|
921
1013
|
const isSpecialRequest = passengerCount > maxVacancies;
|
|
@@ -1255,6 +1347,13 @@ export function PrivateShuttleBookingFlow({
|
|
|
1255
1347
|
}
|
|
1256
1348
|
}, [suggestedStartTimes, isCustomTimeMode, selectedStartTime]);
|
|
1257
1349
|
|
|
1350
|
+
useEffect(() => {
|
|
1351
|
+
if (isAdmin || isCustomTimeMode || !selectedStartTime) return;
|
|
1352
|
+
if (!suggestedStartTimes.includes(selectedStartTime)) {
|
|
1353
|
+
setSelectedStartTime('');
|
|
1354
|
+
}
|
|
1355
|
+
}, [isAdmin, isCustomTimeMode, selectedStartTime, suggestedStartTimes]);
|
|
1356
|
+
|
|
1258
1357
|
useEffect(() => {
|
|
1259
1358
|
if (
|
|
1260
1359
|
pricingConfig?.cancellationPolicies &&
|
|
@@ -1282,6 +1381,14 @@ export function PrivateShuttleBookingFlow({
|
|
|
1282
1381
|
};
|
|
1283
1382
|
|
|
1284
1383
|
const handleStartTimeSelect = (time: string) => {
|
|
1384
|
+
if (
|
|
1385
|
+
!isAdmin &&
|
|
1386
|
+
selectedDate &&
|
|
1387
|
+
!privateShuttleStartTimeAllowed(selectedDate, time, companyTimezone, bookingCutoffNow, bookingCutoffMinutes)
|
|
1388
|
+
) {
|
|
1389
|
+
setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1285
1392
|
setSelectedStartTime(time);
|
|
1286
1393
|
setIsCustomTimeMode(false);
|
|
1287
1394
|
setError('');
|
|
@@ -1465,6 +1572,19 @@ export function PrivateShuttleBookingFlow({
|
|
|
1465
1572
|
setError('Please select a date, start time, and passenger count');
|
|
1466
1573
|
return;
|
|
1467
1574
|
}
|
|
1575
|
+
if (
|
|
1576
|
+
!isAdmin &&
|
|
1577
|
+
!privateShuttleStartTimeAllowed(
|
|
1578
|
+
selectedDate,
|
|
1579
|
+
selectedStartTime,
|
|
1580
|
+
companyTimezone,
|
|
1581
|
+
bookingCutoffNow,
|
|
1582
|
+
bookingCutoffMinutes,
|
|
1583
|
+
)
|
|
1584
|
+
) {
|
|
1585
|
+
setError(privateShuttleBookingCutoffMessage(bookingCutoffMinutes));
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1468
1588
|
if (
|
|
1469
1589
|
product.pickupLocations &&
|
|
1470
1590
|
product.pickupLocations.length > 0 &&
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
"productId": "p_urmbvZepRegd",
|
|
114
114
|
"optionIds": ["po_uO9Ra6NHzwof", "po_44EQlZn24gXg", "po_vuzqpvGk6wVS", "po_nIToR7Qwau3E"],
|
|
115
115
|
"productType": "PRIVATE_SHUTTLE",
|
|
116
|
+
"bookingCutoffMinutes": 2880,
|
|
116
117
|
"display": {
|
|
117
118
|
"path": "/private-shuttle",
|
|
118
119
|
"slug": "private-tour",
|
|
@@ -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
|
}
|