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