@ticketboothapp/booking 1.2.61 → 1.2.63
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/CHANGE_BOOKING_BE_HANDOFF.md +86 -0
- package/package.json +1 -1
- package/src/components/booking/AddOnsSection.tsx +6 -3
- package/src/components/booking/AdminChangeBookingFlow.tsx +4915 -0
- package/src/components/booking/BookingFlow.tsx +23 -5343
- package/src/components/booking/Calendar.tsx +79 -35
- package/src/components/booking/CancellationPolicySelector.tsx +9 -2
- package/src/components/booking/ChangeBookingDialog.tsx +20 -10
- package/src/components/booking/ChangeBookingFlow.tsx +4915 -0
- package/src/components/booking/ChangeBookingPricingDriftPanel.tsx +268 -0
- package/src/components/booking/CheckoutForm.tsx +29 -19
- package/src/components/booking/CurrencySwitcher.tsx +1 -1
- package/src/components/booking/MealDrinkAddOnSelector.tsx +4 -2
- package/src/components/booking/NewBookingFlow.tsx +3256 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +8 -6
- package/src/components/booking/PromoCodeInput.tsx +4 -1
- package/src/components/booking/ReturnTimeSelector.tsx +12 -5
- package/src/components/booking/TicketSelector.tsx +6 -1
- package/src/components/booking/booking-flow-types.ts +141 -0
- package/src/index.ts +10 -1
- package/src/lib/booking/change-booking-pricing-drift.ts +331 -0
- package/src/lib/booking/change-booking-server-preview.ts +139 -0
- package/src/lib/booking/change-flow-pricing.ts +162 -27
- package/src/lib/booking-api.ts +72 -0
|
@@ -0,0 +1,4915 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
|
+
import { parseISO, addWeeks, format, isBefore, isAfter, startOfDay, endOfDay } from 'date-fns';
|
|
5
|
+
import { formatInTimeZone, fromZonedTime } from 'date-fns-tz';
|
|
6
|
+
import {
|
|
7
|
+
getAvailabilities,
|
|
8
|
+
cancelReservation,
|
|
9
|
+
cancelReservationBestEffort,
|
|
10
|
+
createPaymentIntent,
|
|
11
|
+
quoteChangeBooking,
|
|
12
|
+
confirmFreeChangeBooking,
|
|
13
|
+
createChangeBookingPaymentIntent,
|
|
14
|
+
getAddOns,
|
|
15
|
+
validatePromoCode,
|
|
16
|
+
getPromoDiscount,
|
|
17
|
+
type ChangeBookingQuoteResponse,
|
|
18
|
+
type ChangeBookingQuoteTicketPricingTrace,
|
|
19
|
+
type ChangeBookingQuotePricingDriftDetail,
|
|
20
|
+
type Product,
|
|
21
|
+
type Availability,
|
|
22
|
+
type ReturnOption,
|
|
23
|
+
type AddOn,
|
|
24
|
+
isInsufficientCapacityReserveError,
|
|
25
|
+
describeStandardTourCapacityConflictMessage,
|
|
26
|
+
reportReserveCapacityConflictClientContext,
|
|
27
|
+
} from '../../lib/booking-api';
|
|
28
|
+
import {
|
|
29
|
+
EARLIEST_AVAILABILITY_DATE,
|
|
30
|
+
LATEST_AVAILABILITY_DATE,
|
|
31
|
+
INITIAL_FETCH_WEEKS,
|
|
32
|
+
} from '../../lib/booking-constants';
|
|
33
|
+
import { getSundayOfWeek } from '../../lib/booking/sunday-week';
|
|
34
|
+
import { Calendar } from './Calendar';
|
|
35
|
+
import { ItineraryBox } from './ItineraryBox';
|
|
36
|
+
import { ItineraryPlaceholder } from './ItineraryPlaceholder';
|
|
37
|
+
import { PickupTimeSelector } from './PickupTimeSelector';
|
|
38
|
+
import { ReturnTimeSelector } from './ReturnTimeSelector';
|
|
39
|
+
import { TicketSelector } from './TicketSelector';
|
|
40
|
+
import { AddOnsSection } from './AddOnsSection';
|
|
41
|
+
import { CheckoutForm } from './CheckoutForm';
|
|
42
|
+
import { useTranslations, useLocale } from '../../lib/booking/i18n';
|
|
43
|
+
import { CURRENCIES, DEFAULT_CURRENCY, type Currency } from './CurrencySwitcher';
|
|
44
|
+
import { formatBookingRefForDisplay } from '../../lib/booking-ref';
|
|
45
|
+
import {
|
|
46
|
+
effectiveProductOptionIdForChangeFlow,
|
|
47
|
+
isParentProductId,
|
|
48
|
+
normalizeProductOptionIdForChangeFlow,
|
|
49
|
+
} from '../../lib/booking/product-option-id';
|
|
50
|
+
import { formatCurrencyAmount } from '../../lib/currency';
|
|
51
|
+
import {
|
|
52
|
+
resolveChangeFlowNewBookingTotal,
|
|
53
|
+
changeFlowBalanceVsOriginal,
|
|
54
|
+
resolveChangeFlowDisplayedAmounts,
|
|
55
|
+
normalizeNearZeroOwed,
|
|
56
|
+
sliceChangeQuoteForUi,
|
|
57
|
+
changeFlowTicketLineTotalWithReceiptFloor,
|
|
58
|
+
changeFlowFeeLineTotalWithReceiptFloor,
|
|
59
|
+
changeFlowReturnLineTotalWithReceiptFloor,
|
|
60
|
+
type ChangeFlowProtectedReceiptPricing,
|
|
61
|
+
roundMoney,
|
|
62
|
+
} from '../../lib/booking/change-flow-pricing';
|
|
63
|
+
import {
|
|
64
|
+
mergePriceSummaryLinesForDrift,
|
|
65
|
+
normalizePricingDriftDetailFromQuote,
|
|
66
|
+
normalizeTicketPricingTraceFromQuote,
|
|
67
|
+
sumPriceSummaryLinesMajorUnits,
|
|
68
|
+
enrichLineComparisonsWithMergedRows,
|
|
69
|
+
} from '../../lib/booking/change-booking-pricing-drift';
|
|
70
|
+
import { ChangeBookingPricingDriftPanel } from './ChangeBookingPricingDriftPanel';
|
|
71
|
+
import {
|
|
72
|
+
buildChangeBookingServerPreview,
|
|
73
|
+
mapQuoteLineItemsToPriceSummaryLines,
|
|
74
|
+
} from '../../lib/booking/change-booking-server-preview';
|
|
75
|
+
import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
|
|
76
|
+
import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
|
|
77
|
+
import { ItineraryStepType as StepType } from '../../lib/booking-api';
|
|
78
|
+
import {
|
|
79
|
+
getDisplayPriceFromBaseInDisplayCurrency,
|
|
80
|
+
computePriceBreakdown,
|
|
81
|
+
computeOrderSummary,
|
|
82
|
+
type PriceBreakdown as PriceBreakdownData,
|
|
83
|
+
type OrderSummary,
|
|
84
|
+
} from '../../lib/booking/pricing';
|
|
85
|
+
import { useCompanyTimezone } from '../../contexts/CompanyContext';
|
|
86
|
+
import { useBookingApp } from '../../contexts/BookingAppContext';
|
|
87
|
+
import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
|
|
88
|
+
import { type PriceSummaryLine } from './PriceSummary';
|
|
89
|
+
import { CheckoutModal, type CheckoutModalLineItem } from './CheckoutModal';
|
|
90
|
+
import {
|
|
91
|
+
buildBookingSourceContext,
|
|
92
|
+
inferClientBookingSourceFromProductIds,
|
|
93
|
+
type BookingSourceMetadata,
|
|
94
|
+
} from '../../lib/booking/source-metadata';
|
|
95
|
+
import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
|
|
96
|
+
import { MANAGE_BOOKING_FROM_CHANGE_PAYMENT, MANAGE_BOOKING_QUERY_FROM } from '../../lib/manage-booking-post-checkout';
|
|
97
|
+
import { useBookingHost } from '../../runtime';
|
|
98
|
+
import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
99
|
+
import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
|
|
100
|
+
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
101
|
+
|
|
102
|
+
export type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* ## Pricing contract (customer self-serve)
|
|
106
|
+
*
|
|
107
|
+
* Written **as if** `quoteChangeBooking` already returns the full contract (totals + optional maps + line items).
|
|
108
|
+
* Pickers show catalog/server-backed amounts (no `suppress*` gates tied to “waiting for BE”). Checkout prefers
|
|
109
|
+
* `serverPreview.priceSummaryLines` when the quote includes rows; otherwise falls back to FE-built lines after totals confirm.
|
|
110
|
+
*
|
|
111
|
+
* Until the first successful quote, `selfServeCheckoutPlaceholder` still avoids showing unchecked totals.
|
|
112
|
+
*
|
|
113
|
+
*/
|
|
114
|
+
function mergeQuoteSliceWithServerPreview(
|
|
115
|
+
slice: ReturnType<typeof sliceChangeQuoteForUi>,
|
|
116
|
+
quote: ChangeBookingQuoteResponse,
|
|
117
|
+
fallbackCart: { total: number; subtotal: number; tax: number },
|
|
118
|
+
currency: Currency,
|
|
119
|
+
) {
|
|
120
|
+
return {
|
|
121
|
+
...slice,
|
|
122
|
+
currency: (slice.currency || currency) as Currency,
|
|
123
|
+
serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
|
|
124
|
+
pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
|
|
125
|
+
ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const ZERO_PRICE_SUMMARY_LINE = 0.005;
|
|
130
|
+
|
|
131
|
+
/** Change-quote receipt lines may include a $0 promo/discount row — omit from sidebar summary only. */
|
|
132
|
+
function omitZeroAmountPromoDiscountSummaryLines(lines: PriceSummaryLine[]): PriceSummaryLine[] {
|
|
133
|
+
return lines.filter((line) => {
|
|
134
|
+
if (line.kind !== 'line') return true;
|
|
135
|
+
if (Math.abs(line.amount) >= ZERO_PRICE_SUMMARY_LINE) return true;
|
|
136
|
+
const t = String(line.type ?? '').toUpperCase();
|
|
137
|
+
if (t === 'PROMO_CODE' || t === 'DISCOUNT' || t === 'GIFT_CARD' || t === 'VOUCHER') return false;
|
|
138
|
+
const lab = line.label.toLowerCase();
|
|
139
|
+
if (lab.includes('promo')) return false;
|
|
140
|
+
if (lab.includes('voucher') || lab.includes('gift card')) return false;
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatTicketLineItemsForSummary(lines: Array<{ category: string; qty: number }>): string {
|
|
146
|
+
const labels: Record<string, string> = {
|
|
147
|
+
ADULT: 'adult',
|
|
148
|
+
CHILD: 'child',
|
|
149
|
+
INFANT: 'infant',
|
|
150
|
+
SENIOR: 'senior',
|
|
151
|
+
STUDENT: 'student',
|
|
152
|
+
};
|
|
153
|
+
const parts = lines
|
|
154
|
+
.filter((l) => l.qty > 0)
|
|
155
|
+
.map((l) => {
|
|
156
|
+
const label = labels[l.category] || l.category.toLowerCase();
|
|
157
|
+
return `${l.qty} ${label}${l.qty !== 1 ? 's' : ''}`;
|
|
158
|
+
});
|
|
159
|
+
return parts.length > 0 ? parts.join(', ') : '—';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function normalizeTicketCategoryFromReceiptLabel(raw: string): string | null {
|
|
163
|
+
const normalized = raw.trim().toUpperCase();
|
|
164
|
+
if (!normalized) return null;
|
|
165
|
+
if (['ADULT', 'CHILD', 'INFANT', 'SENIOR', 'STUDENT'].includes(normalized)) return normalized;
|
|
166
|
+
const compact = normalized.replace(/[^A-Z]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
167
|
+
if (/\bADULTS?\b/.test(compact)) return 'ADULT';
|
|
168
|
+
if (/\bCHILD(REN)?\b/.test(compact)) return 'CHILD';
|
|
169
|
+
if (/\bINFANTS?\b/.test(compact)) return 'INFANT';
|
|
170
|
+
if (/\bSENIORS?\b/.test(compact)) return 'SENIOR';
|
|
171
|
+
if (/\bSTUDENTS?\b/.test(compact)) return 'STUDENT';
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractTrailingQty(label: string): { baseLabel: string; qty: number } {
|
|
176
|
+
const trimmed = label.trim();
|
|
177
|
+
const m = trimmed.match(/^(.*?)(?:\s*[×x]\s*(\d+))$/);
|
|
178
|
+
if (!m) return { baseLabel: trimmed, qty: 1 };
|
|
179
|
+
const baseLabel = (m[1] || '').trim();
|
|
180
|
+
const qty = Math.max(1, Number(m[2]) || 1);
|
|
181
|
+
return { baseLabel, qty };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeLineLabelForCompare(label: string): string {
|
|
185
|
+
return label
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
.replace(/\([^)]*\)/g, '')
|
|
188
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
189
|
+
.trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function deriveAddOnSelectionsFromReceiptLines(
|
|
193
|
+
addOns: AddOn[],
|
|
194
|
+
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
195
|
+
): Array<{ addOnId: string; variantId?: string; quantity?: number }> {
|
|
196
|
+
const selections: Array<{ addOnId: string; variantId?: string; quantity?: number }> = [];
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
const type = (line.type || '').trim().toUpperCase();
|
|
199
|
+
if (type !== 'FEE') continue;
|
|
200
|
+
const amount = Number(line.amount ?? 0);
|
|
201
|
+
if (!Number.isFinite(amount) || amount <= 0) continue;
|
|
202
|
+
const rawLabel = (line.label || '').trim();
|
|
203
|
+
if (!rawLabel) continue;
|
|
204
|
+
|
|
205
|
+
const { baseLabel, qty } = extractTrailingQty(rawLabel);
|
|
206
|
+
let matched = false;
|
|
207
|
+
|
|
208
|
+
for (const addOn of addOns) {
|
|
209
|
+
if (matched) break;
|
|
210
|
+
const addOnName = addOn.name?.trim();
|
|
211
|
+
if (!addOnName) continue;
|
|
212
|
+
|
|
213
|
+
if (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') {
|
|
214
|
+
for (const variant of addOn.variants ?? []) {
|
|
215
|
+
const variantLabel = variant.label?.trim();
|
|
216
|
+
if (!variantLabel) continue;
|
|
217
|
+
if (baseLabel === `${addOnName} (${variantLabel})`) {
|
|
218
|
+
selections.push({ addOnId: addOn.addOnId, variantId: variant.id, quantity: qty });
|
|
219
|
+
matched = true;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} else if (baseLabel === addOnName) {
|
|
224
|
+
selections.push({ addOnId: addOn.addOnId, quantity: qty });
|
|
225
|
+
matched = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return selections;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function findMergedAvailabilityForSelection(
|
|
233
|
+
merged: Availability[],
|
|
234
|
+
selected: Availability | null
|
|
235
|
+
): Availability | undefined {
|
|
236
|
+
if (!selected) return undefined;
|
|
237
|
+
const optId = selected.productOptionId ?? undefined;
|
|
238
|
+
const dt = selected.dateTime;
|
|
239
|
+
const availId = selected.availabilityId?.trim();
|
|
240
|
+
if (availId && optId) {
|
|
241
|
+
const exact = merged.find(
|
|
242
|
+
(a) =>
|
|
243
|
+
a.availabilityId === availId &&
|
|
244
|
+
(a.productOptionId === optId || a.productOptionId === undefined)
|
|
245
|
+
);
|
|
246
|
+
if (exact) return exact;
|
|
247
|
+
}
|
|
248
|
+
return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
|
|
252
|
+
function buildPricingFromAvailability(
|
|
253
|
+
selectedAvailability: Availability | null,
|
|
254
|
+
activeOptions: Product['options'],
|
|
255
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
256
|
+
currency: Currency,
|
|
257
|
+
pricingConfig: PricingConfig | null,
|
|
258
|
+
hasFees: boolean,
|
|
259
|
+
isSimplifiedPricingView: boolean,
|
|
260
|
+
) {
|
|
261
|
+
if (!selectedAvailability || !pricingConfig) return [];
|
|
262
|
+
const optionId = selectedAvailability.productOptionId;
|
|
263
|
+
const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
|
|
264
|
+
const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
|
|
265
|
+
const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
|
|
266
|
+
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
267
|
+
const getBaseInDisplayCurrency = (category: string) => {
|
|
268
|
+
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
269
|
+
if (fromPrecomputed != null) return fromPrecomputed;
|
|
270
|
+
return 0;
|
|
271
|
+
};
|
|
272
|
+
const buildRate = (
|
|
273
|
+
category: string,
|
|
274
|
+
backendPriceCAD: number,
|
|
275
|
+
backendInDisplayCurrency: number,
|
|
276
|
+
baseInDisplayCurrency: number,
|
|
277
|
+
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
|
|
278
|
+
) => {
|
|
279
|
+
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
280
|
+
const isPublicMode = isSimplifiedPricingView;
|
|
281
|
+
const breakdown = computePriceBreakdown(
|
|
282
|
+
pricingConfig,
|
|
283
|
+
currency,
|
|
284
|
+
backendPriceCAD,
|
|
285
|
+
basePriceCAD,
|
|
286
|
+
hasFees,
|
|
287
|
+
appliedAdjustments,
|
|
288
|
+
undefined,
|
|
289
|
+
baseInDisplayCurrency,
|
|
290
|
+
isPublicMode,
|
|
291
|
+
);
|
|
292
|
+
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
293
|
+
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
294
|
+
};
|
|
295
|
+
return (
|
|
296
|
+
selectedAvailability.rates?.map((rate) => {
|
|
297
|
+
const backendPriceCAD = rate.price ?? 0;
|
|
298
|
+
const backendInDisplayCurrency =
|
|
299
|
+
rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
300
|
+
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
301
|
+
const built = buildRate(
|
|
302
|
+
rate.category,
|
|
303
|
+
backendPriceCAD,
|
|
304
|
+
backendInDisplayCurrency,
|
|
305
|
+
baseInDisplayCurrency,
|
|
306
|
+
rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
|
|
307
|
+
);
|
|
308
|
+
return {
|
|
309
|
+
category: rate.category,
|
|
310
|
+
rateId: rate.rateId || rate.category,
|
|
311
|
+
available: rate.available,
|
|
312
|
+
price: built.price,
|
|
313
|
+
priceCAD: built.priceCAD,
|
|
314
|
+
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
315
|
+
appliedAdjustments: built.appliedAdjustments,
|
|
316
|
+
};
|
|
317
|
+
}) ||
|
|
318
|
+
selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
|
|
319
|
+
const priceCADFromApi = p.price / 100;
|
|
320
|
+
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
321
|
+
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
322
|
+
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
323
|
+
return {
|
|
324
|
+
category: p.category,
|
|
325
|
+
rateId: p.category,
|
|
326
|
+
available: selectedAvailability.vacancies,
|
|
327
|
+
price: built.price,
|
|
328
|
+
priceCAD: built.priceCAD,
|
|
329
|
+
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
330
|
+
appliedAdjustments: built.appliedAdjustments,
|
|
331
|
+
};
|
|
332
|
+
}) ||
|
|
333
|
+
[]
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function findMergedReturnVacancies(
|
|
338
|
+
outbound: Availability | undefined,
|
|
339
|
+
selectedReturn: ReturnOption | null | undefined
|
|
340
|
+
): number | null {
|
|
341
|
+
if (!selectedReturn?.returnAvailabilityId || !outbound?.returnOptions?.length) return null;
|
|
342
|
+
const ro = outbound.returnOptions.find(
|
|
343
|
+
(r) => r.returnAvailabilityId === selectedReturn.returnAvailabilityId
|
|
344
|
+
);
|
|
345
|
+
return ro ? ro.vacancies : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function normalizeAddOnSelections(
|
|
349
|
+
selections: Array<{ addOnId: string; variantId?: string; quantity?: number }>
|
|
350
|
+
): Array<{ addOnId: string; variantId?: string; quantity?: number }> {
|
|
351
|
+
const qtyByKey = new Map<string, number>();
|
|
352
|
+
for (const sel of selections) {
|
|
353
|
+
const addOnId = sel.addOnId?.trim();
|
|
354
|
+
if (!addOnId) continue;
|
|
355
|
+
const variantId = sel.variantId?.trim() || '';
|
|
356
|
+
const key = `${addOnId}::${variantId}`;
|
|
357
|
+
qtyByKey.set(key, (qtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
358
|
+
}
|
|
359
|
+
return Array.from(qtyByKey.entries()).map(([key, qty]) => {
|
|
360
|
+
const [addOnId, variantIdRaw] = key.split('::');
|
|
361
|
+
const variantId = variantIdRaw?.trim() || undefined;
|
|
362
|
+
return { addOnId, variantId, quantity: qty };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseAvailabilityDateTime(value: string): Date {
|
|
367
|
+
// If API omits timezone offset, treat it as UTC to prevent user-local day shifts.
|
|
368
|
+
const hasExplicitOffset = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
|
|
369
|
+
return parseISO(hasExplicitOffset ? value : `${value}Z`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function deriveDefaultQuantitiesFromAvailabilities(
|
|
373
|
+
availabilities: Availability[]
|
|
374
|
+
): Record<string, number> | null {
|
|
375
|
+
const firstWithRates = availabilities.find((avail) => avail.rates && avail.rates.length > 0);
|
|
376
|
+
if (!firstWithRates?.rates?.length) return null;
|
|
377
|
+
|
|
378
|
+
const initialQuantities: Record<string, number> = {};
|
|
379
|
+
firstWithRates.rates.forEach((rate) => {
|
|
380
|
+
initialQuantities[rate.category] = rate.category === 'ADULT' ? 1 : 0;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return Object.keys(initialQuantities).length > 0 ? initialQuantities : null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Clock time (minutes from midnight) for `isoDateTime` in the given IANA zone — use company TZ only, never `Intl` default / browser local. */
|
|
387
|
+
function getMinutesFromMidnightInTimezone(isoDateTime: string, ianaTimeZone: string): number {
|
|
388
|
+
const dt = parseAvailabilityDateTime(isoDateTime);
|
|
389
|
+
const h = parseInt(formatInTimeZone(dt, ianaTimeZone, 'H'), 10);
|
|
390
|
+
const m = parseInt(formatInTimeZone(dt, ianaTimeZone, 'm'), 10);
|
|
391
|
+
return h * 60 + m;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Smallest difference between two clock times on a 24h circle (handles near-midnight edge cases). */
|
|
395
|
+
function minCircularMinutesDiff(a: number, b: number): number {
|
|
396
|
+
const d = Math.abs(a - b);
|
|
397
|
+
return Math.min(d, 24 * 60 - d);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Change-booking only: after a new calendar date, prefer same product option then closest departure time-of-day.
|
|
402
|
+
* `companyTimezone` must be the company IANA zone (e.g. from useCompanyTimezone) — all wall-clock math is there, not user local.
|
|
403
|
+
*/
|
|
404
|
+
function pickOutboundMatchingPreviousSelection(
|
|
405
|
+
timesForSelectedDate: Availability[],
|
|
406
|
+
anchor: { productOptionId: string | null; minutesFromMidnight: number },
|
|
407
|
+
companyTimezone: string,
|
|
408
|
+
optionsMap: Map<string, { mostPopular?: boolean }>,
|
|
409
|
+
): Availability | null {
|
|
410
|
+
const selectable = timesForSelectedDate.filter((a) => (a.vacancies ?? 0) > 0);
|
|
411
|
+
if (selectable.length === 0) return null;
|
|
412
|
+
|
|
413
|
+
const withOption =
|
|
414
|
+
anchor.productOptionId != null
|
|
415
|
+
? selectable.filter((a) => a.productOptionId === anchor.productOptionId)
|
|
416
|
+
: [];
|
|
417
|
+
const pool = withOption.length > 0 ? withOption : selectable;
|
|
418
|
+
|
|
419
|
+
const sorted = [...pool].sort((a, b) => {
|
|
420
|
+
const da = minCircularMinutesDiff(
|
|
421
|
+
getMinutesFromMidnightInTimezone(a.dateTime, companyTimezone),
|
|
422
|
+
anchor.minutesFromMidnight
|
|
423
|
+
);
|
|
424
|
+
const db = minCircularMinutesDiff(
|
|
425
|
+
getMinutesFromMidnightInTimezone(b.dateTime, companyTimezone),
|
|
426
|
+
anchor.minutesFromMidnight
|
|
427
|
+
);
|
|
428
|
+
if (Math.abs(da - db) > 0.5) return da - db;
|
|
429
|
+
const aPop = optionsMap.get(a.productOptionId ?? '')?.mostPopular ? 1 : 0;
|
|
430
|
+
const bPop = optionsMap.get(b.productOptionId ?? '')?.mostPopular ? 1 : 0;
|
|
431
|
+
if (aPop !== bPop) return bPop - aPop;
|
|
432
|
+
return parseAvailabilityDateTime(a.dateTime).getTime() - parseAvailabilityDateTime(b.dateTime).getTime();
|
|
433
|
+
});
|
|
434
|
+
return sorted[0] ?? null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Change-booking only: same return location string then closest return time-of-day in `companyTimezone` (IANA), not user local.
|
|
439
|
+
*/
|
|
440
|
+
function pickReturnMatchingPreviousSelection(
|
|
441
|
+
sortedReturnOptions: ReturnOption[],
|
|
442
|
+
anchor: { returnLocation: string; minutesFromMidnight: number },
|
|
443
|
+
companyTimezone: string,
|
|
444
|
+
): ReturnOption | null {
|
|
445
|
+
const eligible = sortedReturnOptions.filter((o) => (o.vacancies ?? 0) > 0);
|
|
446
|
+
if (eligible.length === 0) return null;
|
|
447
|
+
|
|
448
|
+
const sameLoc = eligible.filter((o) => o.returnLocation === anchor.returnLocation);
|
|
449
|
+
const pool = sameLoc.length > 0 ? sameLoc : eligible;
|
|
450
|
+
|
|
451
|
+
const sorted = [...pool].sort((a, b) => {
|
|
452
|
+
const da = minCircularMinutesDiff(
|
|
453
|
+
getMinutesFromMidnightInTimezone(a.dateTime, companyTimezone),
|
|
454
|
+
anchor.minutesFromMidnight
|
|
455
|
+
);
|
|
456
|
+
const db = minCircularMinutesDiff(
|
|
457
|
+
getMinutesFromMidnightInTimezone(b.dateTime, companyTimezone),
|
|
458
|
+
anchor.minutesFromMidnight
|
|
459
|
+
);
|
|
460
|
+
if (Math.abs(da - db) > 0.5) return da - db;
|
|
461
|
+
return parseAvailabilityDateTime(a.dateTime).getTime() - parseAvailabilityDateTime(b.dateTime).getTime();
|
|
462
|
+
});
|
|
463
|
+
return sorted[0] ?? null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function scoreTicketSubtotalForOption(
|
|
467
|
+
optionId: string | undefined,
|
|
468
|
+
bookingItems: Array<{ category: string; count: number }> | null | undefined,
|
|
469
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
470
|
+
currency: Currency
|
|
471
|
+
): number | null {
|
|
472
|
+
if (!optionId || !bookingItems?.length || !precomputedPricesByOption?.[optionId]) return null;
|
|
473
|
+
const precomputed = precomputedPricesByOption[optionId];
|
|
474
|
+
let sum = 0;
|
|
475
|
+
for (const { category, count } of bookingItems) {
|
|
476
|
+
if (count <= 0) continue;
|
|
477
|
+
const key = category?.trim();
|
|
478
|
+
if (!key) continue;
|
|
479
|
+
const unit =
|
|
480
|
+
precomputed[key]?.[currency] ??
|
|
481
|
+
precomputed[key.toUpperCase()]?.[currency] ??
|
|
482
|
+
precomputed[key.toLowerCase()]?.[currency];
|
|
483
|
+
if (unit == null) return null;
|
|
484
|
+
sum += unit * count;
|
|
485
|
+
}
|
|
486
|
+
return sum;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** When several availabilities share the same departure instant, pick using stored option id or closest ticket subtotal to the original receipt. */
|
|
490
|
+
function disambiguateAvailabilityCandidates(
|
|
491
|
+
candidates: Availability[],
|
|
492
|
+
initialProductOptionId: string | undefined,
|
|
493
|
+
bookingItems: Array<{ category: string; count: number }> | null | undefined,
|
|
494
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
495
|
+
currency: Currency,
|
|
496
|
+
originalSubtotalBeforeTax: number | undefined
|
|
497
|
+
): Availability | null {
|
|
498
|
+
const withVacancyFirst = [...candidates].sort((a, b) => (b.vacancies > 0 ? 1 : 0) - (a.vacancies > 0 ? 1 : 0));
|
|
499
|
+
const pool = withVacancyFirst.length > 0 ? withVacancyFirst : candidates;
|
|
500
|
+
if (pool.length === 0) return null;
|
|
501
|
+
if (pool.length === 1) return pool[0];
|
|
502
|
+
|
|
503
|
+
if (initialProductOptionId) {
|
|
504
|
+
const matched = pool.find((a) => a.productOptionId === initialProductOptionId);
|
|
505
|
+
if (matched) return matched;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (originalSubtotalBeforeTax != null && precomputedPricesByOption && bookingItems?.length) {
|
|
509
|
+
let best: Availability | null = null;
|
|
510
|
+
let bestDiff = Infinity;
|
|
511
|
+
for (const c of pool) {
|
|
512
|
+
const score = scoreTicketSubtotalForOption(c.productOptionId, bookingItems, precomputedPricesByOption, currency);
|
|
513
|
+
if (score == null) continue;
|
|
514
|
+
const diff = Math.abs(score - originalSubtotalBeforeTax);
|
|
515
|
+
if (
|
|
516
|
+
diff < bestDiff - 0.005 ||
|
|
517
|
+
(diff <= bestDiff + 0.005 && (best == null || (c.vacancies > 0 && best.vacancies <= 0)))
|
|
518
|
+
) {
|
|
519
|
+
bestDiff = diff;
|
|
520
|
+
best = c;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (best) return best;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return pool.find((a) => a.vacancies > 0) ?? pool[0];
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Map a stored booking datetime (+ optional ids) to a row in `timesForSelectedDate`.
|
|
531
|
+
* When several options share the same wall time, may defer until `precomputedPricesByOption` is loaded.
|
|
532
|
+
*/
|
|
533
|
+
function resolveInitialAvailabilityFromBooking(
|
|
534
|
+
timesForSelectedDate: Availability[],
|
|
535
|
+
target: Date,
|
|
536
|
+
companyTimezone: string,
|
|
537
|
+
initialAvailabilityId: string | null | undefined,
|
|
538
|
+
initialProductOptionId: string | null | undefined,
|
|
539
|
+
bookingItems: Array<{ category: string; count: number }> | null | undefined,
|
|
540
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
541
|
+
currency: Currency,
|
|
542
|
+
originalSubtotalBeforeTax: number | undefined
|
|
543
|
+
): { selection: Availability | null; defer: boolean } {
|
|
544
|
+
const availId = initialAvailabilityId?.trim() || null;
|
|
545
|
+
const optId = initialProductOptionId?.trim() || null;
|
|
546
|
+
|
|
547
|
+
if (availId) {
|
|
548
|
+
const byAvail = timesForSelectedDate.find((a) => a.availabilityId === availId);
|
|
549
|
+
if (byAvail) return { selection: byAvail, defer: false };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const targetMs = target.getTime();
|
|
553
|
+
const sameInstant = timesForSelectedDate.filter(
|
|
554
|
+
(a) => parseAvailabilityDateTime(a.dateTime).getTime() === targetMs
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
if (sameInstant.length > 0) {
|
|
558
|
+
if (sameInstant.length === 1) {
|
|
559
|
+
return { selection: sameInstant[0], defer: false };
|
|
560
|
+
}
|
|
561
|
+
const ambiguous =
|
|
562
|
+
!optId &&
|
|
563
|
+
!availId &&
|
|
564
|
+
new Set(sameInstant.map((s) => s.productOptionId).filter(Boolean)).size > 1;
|
|
565
|
+
if (ambiguous && !precomputedPricesByOption) {
|
|
566
|
+
return { selection: null, defer: true };
|
|
567
|
+
}
|
|
568
|
+
const picked = disambiguateAvailabilityCandidates(
|
|
569
|
+
sameInstant,
|
|
570
|
+
optId ?? undefined,
|
|
571
|
+
bookingItems,
|
|
572
|
+
precomputedPricesByOption,
|
|
573
|
+
currency,
|
|
574
|
+
originalSubtotalBeforeTax
|
|
575
|
+
);
|
|
576
|
+
return { selection: picked, defer: false };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const localKey = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd HH:mm');
|
|
580
|
+
const sameLocalWall = timesForSelectedDate.filter((a) => {
|
|
581
|
+
const key = formatInTimeZone(parseAvailabilityDateTime(a.dateTime), companyTimezone, 'yyyy-MM-dd HH:mm');
|
|
582
|
+
return key === localKey;
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
if (sameLocalWall.length > 0) {
|
|
586
|
+
if (sameLocalWall.length === 1) {
|
|
587
|
+
return { selection: sameLocalWall[0], defer: false };
|
|
588
|
+
}
|
|
589
|
+
const ambiguous =
|
|
590
|
+
!optId &&
|
|
591
|
+
!availId &&
|
|
592
|
+
new Set(sameLocalWall.map((s) => s.productOptionId).filter(Boolean)).size > 1;
|
|
593
|
+
if (ambiguous && !precomputedPricesByOption) {
|
|
594
|
+
return { selection: null, defer: true };
|
|
595
|
+
}
|
|
596
|
+
const picked = disambiguateAvailabilityCandidates(
|
|
597
|
+
sameLocalWall,
|
|
598
|
+
optId ?? undefined,
|
|
599
|
+
bookingItems,
|
|
600
|
+
precomputedPricesByOption,
|
|
601
|
+
currency,
|
|
602
|
+
originalSubtotalBeforeTax
|
|
603
|
+
);
|
|
604
|
+
return { selection: picked, defer: false };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (optId) {
|
|
608
|
+
const byOption = timesForSelectedDate.filter((a) => a.productOptionId === optId);
|
|
609
|
+
if (byOption.length > 0) {
|
|
610
|
+
const timeMatch = byOption.find((a) => parseAvailabilityDateTime(a.dateTime).getTime() === targetMs);
|
|
611
|
+
if (timeMatch) return { selection: timeMatch, defer: false };
|
|
612
|
+
const localMatch = byOption.find(
|
|
613
|
+
(a) =>
|
|
614
|
+
formatInTimeZone(parseAvailabilityDateTime(a.dateTime), companyTimezone, 'yyyy-MM-dd HH:mm') === localKey
|
|
615
|
+
);
|
|
616
|
+
if (localMatch) return { selection: localMatch, defer: false };
|
|
617
|
+
const picked = disambiguateAvailabilityCandidates(
|
|
618
|
+
byOption,
|
|
619
|
+
optId,
|
|
620
|
+
bookingItems,
|
|
621
|
+
precomputedPricesByOption,
|
|
622
|
+
currency,
|
|
623
|
+
originalSubtotalBeforeTax
|
|
624
|
+
);
|
|
625
|
+
return { selection: picked, defer: false };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const fallback = timesForSelectedDate.find((a) => a.vacancies > 0) ?? null;
|
|
630
|
+
return { selection: fallback, defer: false };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Customer self-serve **change booking** only (no standard new-booking paths).
|
|
635
|
+
* Duplicated from {@link NewBookingFlow} intentionally so each flow can evolve independently.
|
|
636
|
+
*/
|
|
637
|
+
export function AdminChangeBookingFlow({
|
|
638
|
+
product,
|
|
639
|
+
productId,
|
|
640
|
+
onBack,
|
|
641
|
+
currency: currencyFromParent,
|
|
642
|
+
contentRef,
|
|
643
|
+
onSuccess,
|
|
644
|
+
isPartialLaunch = false,
|
|
645
|
+
useWindowScroll = false,
|
|
646
|
+
autoAppliedPromoCode,
|
|
647
|
+
calendarDiscountPercent,
|
|
648
|
+
highlightedPickupLocationIds,
|
|
649
|
+
onPricePreviewChange,
|
|
650
|
+
onChangeFlowSelectionPreview,
|
|
651
|
+
originalReceipt = null,
|
|
652
|
+
initialValues,
|
|
653
|
+
hideItineraryBox = false,
|
|
654
|
+
flowUi,
|
|
655
|
+
bookingSourceAttribution,
|
|
656
|
+
partnerPortalBooking = false,
|
|
657
|
+
availabilityPricingProfileId,
|
|
658
|
+
availabilityCancellationPolicyProfileId,
|
|
659
|
+
}: ChangeBookingFlowProps) {
|
|
660
|
+
/** Always the booking’s sold currency — not the site currency switcher / parent default. */
|
|
661
|
+
const currency = useMemo((): Currency => {
|
|
662
|
+
const fromReceipt = originalReceipt?.currency;
|
|
663
|
+
if (fromReceipt && CURRENCIES.includes(fromReceipt)) return fromReceipt;
|
|
664
|
+
const fromInitial = initialValues?.currency;
|
|
665
|
+
if (fromInitial && CURRENCIES.includes(fromInitial)) return fromInitial;
|
|
666
|
+
if (currencyFromParent && CURRENCIES.includes(currencyFromParent)) return currencyFromParent;
|
|
667
|
+
return DEFAULT_CURRENCY;
|
|
668
|
+
}, [originalReceipt?.currency, initialValues?.currency, currencyFromParent]);
|
|
669
|
+
|
|
670
|
+
const { env, analytics } = useBookingHost();
|
|
671
|
+
const { t } = useTranslations();
|
|
672
|
+
const { locale } = useLocale();
|
|
673
|
+
const companyTimezone = useCompanyTimezone(); // Get timezone from context
|
|
674
|
+
const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
|
|
675
|
+
const cancellationPolicyProfileIdForAvailabilities =
|
|
676
|
+
(availabilityCancellationPolicyProfileId ?? '').trim() || null;
|
|
677
|
+
const {
|
|
678
|
+
isSimplifiedPricingView,
|
|
679
|
+
onShowManage,
|
|
680
|
+
getSuccessUrl,
|
|
681
|
+
suppressCalendarDateScroll,
|
|
682
|
+
} = useBookingApp();
|
|
683
|
+
const availabilitiesCache = useAvailabilitiesCache();
|
|
684
|
+
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
|
|
685
|
+
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
686
|
+
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
687
|
+
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
|
688
|
+
const [email, setEmail] = useState('');
|
|
689
|
+
const [firstName, setFirstName] = useState('');
|
|
690
|
+
const [lastName, setLastName] = useState('');
|
|
691
|
+
const [promoCodeInput, setPromoCodeInput] = useState('');
|
|
692
|
+
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
|
|
693
|
+
const [promoCodeError, setPromoCodeError] = useState('');
|
|
694
|
+
/** Dedupe parent updates from `onChangeFlowSelectionPreview` when serialized preview is unchanged. */
|
|
695
|
+
const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
|
|
696
|
+
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
697
|
+
const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
|
|
698
|
+
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
699
|
+
/** Cancellation policy is fixed to the existing booking snapshot — not user-editable in change flow. */
|
|
700
|
+
const cancellationPolicyId = useMemo(
|
|
701
|
+
() => initialValues?.cancellationPolicyId?.trim() ?? null,
|
|
702
|
+
[initialValues?.cancellationPolicyId],
|
|
703
|
+
);
|
|
704
|
+
/** Add-on selections (lunch, animals, etc.) - filtered by selected product option */
|
|
705
|
+
const [addOnSelections, setAddOnSelections] = useState<Array<{ addOnId: string; variantId?: string; quantity?: number }>>(() =>
|
|
706
|
+
normalizeAddOnSelections(initialValues?.addOnSelections ?? [])
|
|
707
|
+
);
|
|
708
|
+
/** Fetched add-ons for the selected product option */
|
|
709
|
+
const [addOns, setAddOns] = useState<AddOn[]>([]);
|
|
710
|
+
|
|
711
|
+
// Auto-apply promo code when parent page passes one (e.g. partner pages).
|
|
712
|
+
// Seed input only; validate/apply runs after date/time + tickets exist (debounced + handleApplyPromo).
|
|
713
|
+
useEffect(() => {
|
|
714
|
+
if (!autoAppliedPromoCode) return;
|
|
715
|
+
const normalizedPromo = autoAppliedPromoCode.trim().toUpperCase();
|
|
716
|
+
if (!normalizedPromo) return;
|
|
717
|
+
setPromoCodeInput((current) => current || normalizedPromo);
|
|
718
|
+
}, [autoAppliedPromoCode]);
|
|
719
|
+
const [loading, setLoading] = useState(false);
|
|
720
|
+
const [loadingAvailabilities, setLoadingAvailabilities] = useState(true);
|
|
721
|
+
/** True when fetching additional availability (e.g. new month in dropdown) - shows spinner in date picker */
|
|
722
|
+
const [isFetchingMoreAvailabilities, setIsFetchingMoreAvailabilities] = useState(false);
|
|
723
|
+
const [error, setError] = useState('');
|
|
724
|
+
const [pricingConfig, setPricingConfig] = useState<PricingConfig | null>(null);
|
|
725
|
+
/** Precomputed prices from ticketbooth-product-prices per option (optionId -> category -> currency -> price). Used for display; rates[].price is for GYG only. */
|
|
726
|
+
const [precomputedPricesByOption, setPrecomputedPricesByOption] = useState<Record<string, PrecomputedPricesByCategory> | null>(null);
|
|
727
|
+
const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
|
|
728
|
+
const fetchingRef = useRef(false); // Prevent concurrent fetches
|
|
729
|
+
const hasLoadedAvailabilitiesRef = useRef(false); // First successful availability paint completed
|
|
730
|
+
const inFlightRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range currently being fetched
|
|
731
|
+
const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
|
|
732
|
+
const pendingRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range to fetch when current fetch completes (user navigated during fetch)
|
|
733
|
+
const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
|
|
734
|
+
const [selectedDate, setSelectedDate] = useState<string>(() => {
|
|
735
|
+
if (!initialValues?.dateTime?.trim()) return '';
|
|
736
|
+
try {
|
|
737
|
+
const target = parseAvailabilityDateTime(initialValues.dateTime.trim());
|
|
738
|
+
return formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
|
|
739
|
+
} catch {
|
|
740
|
+
return '';
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
const [isItinerarySticky, setIsItinerarySticky] = useState(false);
|
|
744
|
+
const isItineraryStickyRef = useRef(false);
|
|
745
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
746
|
+
const [showTooltip, setShowTooltip] = useState(false);
|
|
747
|
+
const itineraryRef = useRef<HTMLDivElement>(null);
|
|
748
|
+
const [showCheckoutModal, setShowCheckoutModal] = useState(false);
|
|
749
|
+
/** Pending reservation while user is in checkout (RESERVED until confirmed/cancelled). */
|
|
750
|
+
const pendingReservationRef = useRef<{ reservationReference: string } | null>(null);
|
|
751
|
+
/** True while Stripe is confirming payment/redirecting; skip unload cancellation during this window. */
|
|
752
|
+
const paymentSubmitInFlightRef = useRef(false);
|
|
753
|
+
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
754
|
+
const [termsAcceptedAt, setTermsAcceptedAt] = useState<string | null>(null);
|
|
755
|
+
const [partnerAttributionConfirmed, setPartnerAttributionConfirmed] = useState(false);
|
|
756
|
+
const [checkoutClientSecret, setCheckoutClientSecret] = useState('');
|
|
757
|
+
const [checkoutModalData, setCheckoutModalData] = useState<{
|
|
758
|
+
reservationReference: string;
|
|
759
|
+
reservationExpiration?: string;
|
|
760
|
+
customerLastName?: string;
|
|
761
|
+
bookingDate?: string;
|
|
762
|
+
successUrlOverride?: string;
|
|
763
|
+
ticketLines: CheckoutModalLineItem[];
|
|
764
|
+
feeLineItems: OrderSummary['feeLineItems'];
|
|
765
|
+
returnPriceAdjustment: number;
|
|
766
|
+
cancellationPolicyFee: number;
|
|
767
|
+
cancellationPolicyLabel?: string;
|
|
768
|
+
subtotal: number;
|
|
769
|
+
tax: number;
|
|
770
|
+
total: number;
|
|
771
|
+
promoDiscountAmount?: number;
|
|
772
|
+
discountLabel?: string | null;
|
|
773
|
+
totalQuantity: number;
|
|
774
|
+
isTaxIncludedInPrice: boolean;
|
|
775
|
+
taxRate: number;
|
|
776
|
+
changeTotals?: {
|
|
777
|
+
previousTotal: number;
|
|
778
|
+
newTotal: number;
|
|
779
|
+
differenceTotal: number;
|
|
780
|
+
};
|
|
781
|
+
} | null>(null);
|
|
782
|
+
const hasAppliedInitialValuesRef = useRef(false);
|
|
783
|
+
const hasAppliedInitialQuantitiesRef = useRef(false);
|
|
784
|
+
const hasHydratedAddOnsFromReceiptRef = useRef(false);
|
|
785
|
+
const hasAutoSelectedPartnerDateRef = useRef(false);
|
|
786
|
+
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
787
|
+
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
788
|
+
const changeFlowOriginalDate = useMemo(() => {
|
|
789
|
+
if (!initialValues?.dateTime?.trim()) return null;
|
|
790
|
+
try {
|
|
791
|
+
return formatInTimeZone(
|
|
792
|
+
parseAvailabilityDateTime(initialValues.dateTime.trim()),
|
|
793
|
+
companyTimezone,
|
|
794
|
+
'yyyy-MM-dd',
|
|
795
|
+
);
|
|
796
|
+
} catch {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
}, [initialValues?.dateTime, companyTimezone]);
|
|
800
|
+
/** Do not render catalog-/FE-derived dollar amounts in UI until `quoteChangeBooking` returns `serverDisplay`. */
|
|
801
|
+
const suppressSelfServeCurrencyUi = true;
|
|
802
|
+
|
|
803
|
+
useEffect(() => {
|
|
804
|
+
setPartnerAttributionConfirmed(false);
|
|
805
|
+
}, [flowUi?.partnerAttributionSummary, flowUi?.partnerAttributionConfirmLabel]);
|
|
806
|
+
/**
|
|
807
|
+
* Change flow: if the booking payload has no return id/datetime, we still need to detect when the
|
|
808
|
+
* user picks a different return time — baseline is the first auto-selected return for this outbound.
|
|
809
|
+
*/
|
|
810
|
+
const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
|
|
811
|
+
/** Promo from booking is fixed — show read-only, never add new. */
|
|
812
|
+
const lockedPromoCode = initialValues?.promoCode?.trim()
|
|
813
|
+
? initialValues.promoCode.trim().toUpperCase()
|
|
814
|
+
: null;
|
|
815
|
+
/** Public self-serve only: cannot reduce tickets below original counts. */
|
|
816
|
+
const changeBookingMinimumQuantities = useMemo(() => {
|
|
817
|
+
if (!initialValues?.bookingItems?.length) return undefined;
|
|
818
|
+
const m: Record<string, number> = {};
|
|
819
|
+
for (const item of initialValues.bookingItems) {
|
|
820
|
+
const key = item.category?.trim();
|
|
821
|
+
if (!key) continue;
|
|
822
|
+
m[key] = Math.max(0, Number(item.count) || 0);
|
|
823
|
+
}
|
|
824
|
+
return m;
|
|
825
|
+
}, [initialValues?.bookingItems]);
|
|
826
|
+
const [latestChangeQuote, setLatestChangeQuote] = useState<{
|
|
827
|
+
priceDiff: number;
|
|
828
|
+
currency: Currency;
|
|
829
|
+
canProceed: boolean;
|
|
830
|
+
reasonIfBlocked?: string;
|
|
831
|
+
changeIntentId?: string;
|
|
832
|
+
quotedTotal?: number;
|
|
833
|
+
/** From `quoteChangeBooking` receipt fields — drives PriceSummary when self-serve. */
|
|
834
|
+
serverDisplay?: { total: number; subtotal: number; tax: number };
|
|
835
|
+
/** Parsed from last quote — unified server-owned preview for lines + picker overrides. */
|
|
836
|
+
serverPreview: ReturnType<typeof buildChangeBookingServerPreview>;
|
|
837
|
+
pricingDriftDetail?: ChangeBookingQuotePricingDriftDetail;
|
|
838
|
+
ticketPricingTrace?: ChangeBookingQuoteTicketPricingTrace | null;
|
|
839
|
+
} | null>(null);
|
|
840
|
+
const [changeQuoteLoading, setChangeQuoteLoading] = useState(false);
|
|
841
|
+
const [changeQuoteFetchError, setChangeQuoteFetchError] = useState<string | null>(null);
|
|
842
|
+
const selfServePricingConfirmed =
|
|
843
|
+
suppressSelfServeCurrencyUi &&
|
|
844
|
+
latestChangeQuote != null &&
|
|
845
|
+
changeQuoteFetchError == null &&
|
|
846
|
+
latestChangeQuote.canProceed !== false &&
|
|
847
|
+
latestChangeQuote.serverDisplay != null;
|
|
848
|
+
const changeQuoteRequestSeq = useRef(0);
|
|
849
|
+
/** When the user picks a new calendar date in change flow, we match outbound/return from these anchors. */
|
|
850
|
+
const changeFlowOutboundAnchorRef = useRef<{
|
|
851
|
+
productOptionId: string | null;
|
|
852
|
+
minutesFromMidnight: number;
|
|
853
|
+
} | null>(null);
|
|
854
|
+
const changeFlowReturnAnchorRef = useRef<{
|
|
855
|
+
returnLocation: string;
|
|
856
|
+
minutesFromMidnight: number;
|
|
857
|
+
} | null>(null);
|
|
858
|
+
|
|
859
|
+
// Get all active product options (memoized to prevent recreating array on each render)
|
|
860
|
+
const activeOptions = useMemo(() =>
|
|
861
|
+
product.options?.filter(opt => opt.status === 'ACTIVE') || [],
|
|
862
|
+
[product.options]
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
// Detect if this is a Private Shuttle product
|
|
866
|
+
const isPrivateShuttle = product.productType === 'PRIVATE_SHUTTLE';
|
|
867
|
+
|
|
868
|
+
// Create stable string key from option IDs for dependency array
|
|
869
|
+
const activeOptionIdsKey = useMemo(() =>
|
|
870
|
+
activeOptions.map(opt => opt.optionId).sort().join(','),
|
|
871
|
+
[activeOptions]
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
// Create a Map for O(1) option lookups by optionId (performance optimization)
|
|
875
|
+
const optionsMap = useMemo(() => {
|
|
876
|
+
const map = new Map<string, typeof activeOptions[0]>();
|
|
877
|
+
activeOptions.forEach(opt => map.set(opt.optionId, opt));
|
|
878
|
+
return map;
|
|
879
|
+
}, [activeOptions]);
|
|
880
|
+
|
|
881
|
+
// Fire view_item when product is first displayed
|
|
882
|
+
const hasFiredViewItem = useRef(false);
|
|
883
|
+
useEffect(() => {
|
|
884
|
+
if (!hasFiredViewItem.current && product) {
|
|
885
|
+
hasFiredViewItem.current = true;
|
|
886
|
+
const id = productId || product.productId;
|
|
887
|
+
const price = product.minPriceByCurrency?.[currency] ?? 0;
|
|
888
|
+
analytics.trackViewItem(id, product.name, price, currency);
|
|
889
|
+
}
|
|
890
|
+
}, [product, productId, currency]);
|
|
891
|
+
|
|
892
|
+
// Helper function to check if we need to fetch a date range
|
|
893
|
+
const needsFetch = (start: Date, end: Date): boolean => {
|
|
894
|
+
if (fetchedRangesRef.current.length === 0) return true;
|
|
895
|
+
|
|
896
|
+
// Check if the requested range is fully covered by fetched ranges
|
|
897
|
+
// For simplicity, check if any single fetched range fully covers the requested range
|
|
898
|
+
return !fetchedRangesRef.current.some(range => {
|
|
899
|
+
const rangeStart = range.start.getTime();
|
|
900
|
+
const rangeEnd = range.end.getTime();
|
|
901
|
+
const reqStart = start.getTime();
|
|
902
|
+
const reqEnd = end.getTime();
|
|
903
|
+
|
|
904
|
+
// Check if this fetched range fully covers the requested range
|
|
905
|
+
return rangeStart <= reqStart && rangeEnd >= reqEnd;
|
|
906
|
+
});
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/** Re-fetch current calendar window so vacancies/booked counts match server after a reserve race. */
|
|
910
|
+
const reloadAvailabilitiesAfterReserveConflict = useCallback(async (): Promise<Availability[]> => {
|
|
911
|
+
if (isPartialLaunch || !visibleRange || activeOptions.length === 0) {
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
|
|
916
|
+
const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
|
|
917
|
+
const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
|
|
918
|
+
? startOfEarliestDay
|
|
919
|
+
: visibleRange.start;
|
|
920
|
+
let clampedEnd = isAfter(visibleRange.end, endOfLatestDay) ? endOfLatestDay : visibleRange.end;
|
|
921
|
+
|
|
922
|
+
if (selectedDate) {
|
|
923
|
+
try {
|
|
924
|
+
const selectedDateObj = parseISO(selectedDate);
|
|
925
|
+
if (isAfter(selectedDateObj, clampedEnd)) {
|
|
926
|
+
clampedEnd = selectedDateObj;
|
|
927
|
+
}
|
|
928
|
+
} catch {
|
|
929
|
+
/* ignore */
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
let startDateStr: string;
|
|
934
|
+
let endDateStr: string;
|
|
935
|
+
|
|
936
|
+
if (isPrivateShuttle) {
|
|
937
|
+
startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
|
|
938
|
+
endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
|
|
939
|
+
} else {
|
|
940
|
+
const startDateInTz = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
|
|
941
|
+
const endDateInTz = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
|
|
942
|
+
const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
|
|
943
|
+
const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
|
|
944
|
+
startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
945
|
+
endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const availabilityPromises = activeOptions.map(async (option) => {
|
|
949
|
+
const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
|
|
950
|
+
promoCode: appliedPromoCode || undefined,
|
|
951
|
+
...(pricingProfileIdForAvailabilities
|
|
952
|
+
? { pricingProfileId: pricingProfileIdForAvailabilities }
|
|
953
|
+
: {}),
|
|
954
|
+
...(cancellationPolicyProfileIdForAvailabilities
|
|
955
|
+
? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
|
|
956
|
+
: {}),
|
|
957
|
+
});
|
|
958
|
+
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
959
|
+
setPricingConfig((prev) => {
|
|
960
|
+
if (!prev) {
|
|
961
|
+
pricingConfigSetRef.current = true;
|
|
962
|
+
return result.pricingConfig!;
|
|
963
|
+
}
|
|
964
|
+
return prev;
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
optionId: option.optionId,
|
|
969
|
+
availabilities: result.availabilities.map((avail) => ({
|
|
970
|
+
...avail,
|
|
971
|
+
productOptionId: option.optionId,
|
|
972
|
+
})),
|
|
973
|
+
precomputedPrices: result.precomputedPrices,
|
|
974
|
+
pricingConfig: result.pricingConfig,
|
|
975
|
+
};
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const results = await Promise.all(availabilityPromises);
|
|
979
|
+
const allFetchedAvailabilities = results.flatMap((r) => r.availabilities);
|
|
980
|
+
|
|
981
|
+
setPrecomputedPricesByOption((prev) => {
|
|
982
|
+
const next = { ...(prev || {}) };
|
|
983
|
+
results.forEach((r) => {
|
|
984
|
+
if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
|
|
985
|
+
next[r.optionId] = r.precomputedPrices;
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
return Object.keys(next).length > 0 ? next : prev;
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
let mergedOut: Availability[] = [];
|
|
992
|
+
setAvailabilities((prev) => {
|
|
993
|
+
const existingMap = new Map(
|
|
994
|
+
prev.map((avail) => [`${avail.dateTime}-${avail.productOptionId}`, avail])
|
|
995
|
+
);
|
|
996
|
+
allFetchedAvailabilities.forEach((avail) => {
|
|
997
|
+
existingMap.set(`${avail.dateTime}-${avail.productOptionId}`, avail);
|
|
998
|
+
});
|
|
999
|
+
mergedOut = Array.from(existingMap.values());
|
|
1000
|
+
return mergedOut;
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
|
|
1004
|
+
fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
1005
|
+
const mergedRanges: Array<{ start: Date; end: Date }> = [];
|
|
1006
|
+
for (const r of fetchedRangesRef.current) {
|
|
1007
|
+
if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].end < r.start) {
|
|
1008
|
+
mergedRanges.push({ start: r.start, end: r.end });
|
|
1009
|
+
} else {
|
|
1010
|
+
mergedRanges[mergedRanges.length - 1].end =
|
|
1011
|
+
r.end > mergedRanges[mergedRanges.length - 1].end
|
|
1012
|
+
? r.end
|
|
1013
|
+
: mergedRanges[mergedRanges.length - 1].end;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
fetchedRangesRef.current = mergedRanges;
|
|
1017
|
+
|
|
1018
|
+
const cacheKey = availabilitiesCache
|
|
1019
|
+
? buildAvailabilitiesCacheKey(
|
|
1020
|
+
product.productId,
|
|
1021
|
+
activeOptionIdsKey,
|
|
1022
|
+
appliedPromoCode,
|
|
1023
|
+
pricingProfileIdForAvailabilities,
|
|
1024
|
+
)
|
|
1025
|
+
: null;
|
|
1026
|
+
if (cacheKey && availabilitiesCache) {
|
|
1027
|
+
const existingCache = availabilitiesCache.get(cacheKey);
|
|
1028
|
+
const existingAvailabilities = existingCache?.availabilities ?? [];
|
|
1029
|
+
const mergedAvailabilitiesMap = new Map(
|
|
1030
|
+
existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
|
|
1031
|
+
);
|
|
1032
|
+
allFetchedAvailabilities.forEach((a) => {
|
|
1033
|
+
mergedAvailabilitiesMap.set(`${a.dateTime}-${a.productOptionId}`, a);
|
|
1034
|
+
});
|
|
1035
|
+
const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
|
|
1036
|
+
results.forEach((r) => {
|
|
1037
|
+
if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
|
|
1038
|
+
mergedPrecomputed[r.optionId] = r.precomputedPrices;
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
const firstPricingConfig =
|
|
1042
|
+
(results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ??
|
|
1043
|
+
existingCache?.pricingConfig ??
|
|
1044
|
+
null;
|
|
1045
|
+
availabilitiesCache.merge(cacheKey, {
|
|
1046
|
+
fetchedRanges: mergedRanges,
|
|
1047
|
+
availabilities: Array.from(mergedAvailabilitiesMap.values()),
|
|
1048
|
+
pricingConfig: firstPricingConfig,
|
|
1049
|
+
precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return mergedOut;
|
|
1054
|
+
}, [
|
|
1055
|
+
isPartialLaunch,
|
|
1056
|
+
visibleRange,
|
|
1057
|
+
companyTimezone,
|
|
1058
|
+
selectedDate,
|
|
1059
|
+
isPrivateShuttle,
|
|
1060
|
+
activeOptions,
|
|
1061
|
+
appliedPromoCode,
|
|
1062
|
+
pricingProfileIdForAvailabilities,
|
|
1063
|
+
cancellationPolicyProfileIdForAvailabilities,
|
|
1064
|
+
product.productId,
|
|
1065
|
+
activeOptionIdsKey,
|
|
1066
|
+
availabilitiesCache,
|
|
1067
|
+
]);
|
|
1068
|
+
|
|
1069
|
+
// Initialize visible range when unset. Change flow: start the fetch window on the booking week so
|
|
1070
|
+
// availability for the current trip loads first; otherwise anchor at season open for fast first paint.
|
|
1071
|
+
useEffect(() => {
|
|
1072
|
+
if (!visibleRange) {
|
|
1073
|
+
const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
|
|
1074
|
+
const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
|
|
1075
|
+
|
|
1076
|
+
if (initialValues?.dateTime?.trim()) {
|
|
1077
|
+
try {
|
|
1078
|
+
const target = parseAvailabilityDateTime(initialValues.dateTime.trim());
|
|
1079
|
+
const dateStr = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
|
|
1080
|
+
const weekStartStr = getSundayOfWeek(dateStr, companyTimezone);
|
|
1081
|
+
let rangeStart = fromZonedTime(parseISO(`${weekStartStr}T12:00:00`), companyTimezone);
|
|
1082
|
+
if (isBefore(rangeStart, startOfEarliestDay)) {
|
|
1083
|
+
rangeStart = startOfEarliestDay;
|
|
1084
|
+
}
|
|
1085
|
+
let rangeEnd = addWeeks(rangeStart, INITIAL_FETCH_WEEKS);
|
|
1086
|
+
if (isAfter(rangeEnd, endOfLatestDay)) {
|
|
1087
|
+
rangeEnd = endOfLatestDay;
|
|
1088
|
+
}
|
|
1089
|
+
if (!isBefore(rangeEnd, rangeStart)) {
|
|
1090
|
+
setVisibleRange({ start: rangeStart, end: rangeEnd });
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
} catch {
|
|
1094
|
+
/* fall through to default */
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const initialEnd = addWeeks(EARLIEST_AVAILABILITY_DATE, INITIAL_FETCH_WEEKS);
|
|
1099
|
+
setVisibleRange({ start: EARLIEST_AVAILABILITY_DATE, end: initialEnd });
|
|
1100
|
+
}
|
|
1101
|
+
}, [visibleRange, initialValues?.dateTime, companyTimezone]);
|
|
1102
|
+
|
|
1103
|
+
// Fetch availabilities for visible range + buffer
|
|
1104
|
+
useEffect(() => {
|
|
1105
|
+
if (isPartialLaunch) {
|
|
1106
|
+
setLoadingAvailabilities(false);
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (activeOptions.length === 0) {
|
|
1110
|
+
setError('No active product options available');
|
|
1111
|
+
setLoadingAvailabilities(false);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (!visibleRange) {
|
|
1116
|
+
// Wait for initial range to be set
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
async function fetchAvailabilities() {
|
|
1121
|
+
// Prevent concurrent fetches - store range to fetch when current one completes
|
|
1122
|
+
if (fetchingRef.current && visibleRange) {
|
|
1123
|
+
const inFlight = inFlightRangeRef.current;
|
|
1124
|
+
if (
|
|
1125
|
+
inFlight &&
|
|
1126
|
+
inFlight.start.getTime() === visibleRange.start.getTime() &&
|
|
1127
|
+
inFlight.end.getTime() === visibleRange.end.getTime()
|
|
1128
|
+
) {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
pendingRangeRef.current = { start: visibleRange.start, end: visibleRange.end };
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Clamp to available date range (use company timezone - date-fns startOfDay/endOfDay use local TZ which can exclude Oct 12)
|
|
1136
|
+
// For end date, we need end of Oct 12 in company timezone (inclusive)
|
|
1137
|
+
if (!visibleRange) return;
|
|
1138
|
+
|
|
1139
|
+
const startOfEarliestDay = fromZonedTime(new Date(2026, 5, 1, 0, 0, 0, 0), companyTimezone);
|
|
1140
|
+
const endOfLatestDay = fromZonedTime(new Date(2026, 9, 12, 23, 59, 59, 999), companyTimezone);
|
|
1141
|
+
const clampedStart = isBefore(visibleRange.start, startOfEarliestDay)
|
|
1142
|
+
? startOfEarliestDay
|
|
1143
|
+
: visibleRange.start;
|
|
1144
|
+
let clampedEnd = isAfter(visibleRange.end, endOfLatestDay)
|
|
1145
|
+
? endOfLatestDay
|
|
1146
|
+
: visibleRange.end;
|
|
1147
|
+
|
|
1148
|
+
// Ensure we include the selected date if it's after the visible range end
|
|
1149
|
+
// This handles the case where user selects a date that's not yet in the visible range
|
|
1150
|
+
if (selectedDate) {
|
|
1151
|
+
try {
|
|
1152
|
+
const selectedDateObj = parseISO(selectedDate);
|
|
1153
|
+
if (isAfter(selectedDateObj, clampedEnd)) {
|
|
1154
|
+
clampedEnd = selectedDateObj;
|
|
1155
|
+
}
|
|
1156
|
+
} catch {
|
|
1157
|
+
// Ignore parse errors
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Check cache first - avoid refetch when reopening same product
|
|
1162
|
+
const cacheKey = availabilitiesCache
|
|
1163
|
+
? buildAvailabilitiesCacheKey(
|
|
1164
|
+
product.productId,
|
|
1165
|
+
activeOptionIdsKey,
|
|
1166
|
+
appliedPromoCode,
|
|
1167
|
+
pricingProfileIdForAvailabilities,
|
|
1168
|
+
)
|
|
1169
|
+
: null;
|
|
1170
|
+
const cached = cacheKey ? availabilitiesCache!.get(cacheKey) : undefined;
|
|
1171
|
+
if (cached && cached.availabilities.length > 0) {
|
|
1172
|
+
const cachedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(cached.availabilities);
|
|
1173
|
+
if (cachedInitialQuantities) {
|
|
1174
|
+
setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : cachedInitialQuantities));
|
|
1175
|
+
}
|
|
1176
|
+
const cacheCoversRange = cached.fetchedRanges.some(
|
|
1177
|
+
(r) => r.start.getTime() <= clampedStart.getTime() && r.end.getTime() >= clampedEnd.getTime()
|
|
1178
|
+
);
|
|
1179
|
+
const isStale = availabilitiesCache?.isStale(cached) ?? false;
|
|
1180
|
+
if (cacheCoversRange) {
|
|
1181
|
+
setAvailabilities(cached.availabilities);
|
|
1182
|
+
if (cached.availabilities.length > 0) {
|
|
1183
|
+
hasLoadedAvailabilitiesRef.current = true;
|
|
1184
|
+
}
|
|
1185
|
+
if (cached.pricingConfig) {
|
|
1186
|
+
setPricingConfig(cached.pricingConfig);
|
|
1187
|
+
pricingConfigSetRef.current = true;
|
|
1188
|
+
}
|
|
1189
|
+
if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
|
|
1190
|
+
setPrecomputedPricesByOption(cached.precomputedPricesByOption);
|
|
1191
|
+
}
|
|
1192
|
+
setLoadingAvailabilities(false);
|
|
1193
|
+
setIsFetchingMoreAvailabilities(false);
|
|
1194
|
+
if (!isStale) {
|
|
1195
|
+
fetchedRangesRef.current = [...cached.fetchedRanges];
|
|
1196
|
+
fetchingRef.current = false;
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
// Stale-while-revalidate: show cached data, fetch in background (don't set fetchedRangesRef so we fall through)
|
|
1200
|
+
}
|
|
1201
|
+
// Partial cache: show cached data immediately, then fetch missing range below
|
|
1202
|
+
setAvailabilities(cached.availabilities);
|
|
1203
|
+
if (cached.availabilities.length > 0) {
|
|
1204
|
+
hasLoadedAvailabilitiesRef.current = true;
|
|
1205
|
+
}
|
|
1206
|
+
if (cached.pricingConfig) {
|
|
1207
|
+
setPricingConfig(cached.pricingConfig);
|
|
1208
|
+
pricingConfigSetRef.current = true;
|
|
1209
|
+
}
|
|
1210
|
+
if (cached.precomputedPricesByOption && Object.keys(cached.precomputedPricesByOption).length > 0) {
|
|
1211
|
+
setPrecomputedPricesByOption(cached.precomputedPricesByOption);
|
|
1212
|
+
}
|
|
1213
|
+
fetchedRangesRef.current = [...cached.fetchedRanges];
|
|
1214
|
+
setLoadingAvailabilities(false);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Check if we need to fetch this range
|
|
1218
|
+
// Backend always returns CAD prices without fee/tax, so no need to refetch on currency change
|
|
1219
|
+
const shouldFetch = needsFetch(clampedStart, clampedEnd);
|
|
1220
|
+
if (!shouldFetch) {
|
|
1221
|
+
// Range already fetched - ensure loading state is cleared
|
|
1222
|
+
setLoadingAvailabilities(false);
|
|
1223
|
+
setIsFetchingMoreAvailabilities(false);
|
|
1224
|
+
fetchingRef.current = false;
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const hasPartialCache = cached && cached.availabilities.length > 0;
|
|
1229
|
+
const shouldUsePrimaryLoader =
|
|
1230
|
+
!hasPartialCache && !hasLoadedAvailabilitiesRef.current;
|
|
1231
|
+
fetchingRef.current = true;
|
|
1232
|
+
inFlightRangeRef.current = {
|
|
1233
|
+
start: new Date(clampedStart),
|
|
1234
|
+
end: new Date(clampedEnd),
|
|
1235
|
+
};
|
|
1236
|
+
if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
|
|
1237
|
+
else setIsFetchingMoreAvailabilities(true);
|
|
1238
|
+
|
|
1239
|
+
try {
|
|
1240
|
+
let startDateStr: string;
|
|
1241
|
+
let endDateStr: string;
|
|
1242
|
+
|
|
1243
|
+
if (isPrivateShuttle) {
|
|
1244
|
+
// Private Shuttle: use date-only format (YYYY-MM-DD)
|
|
1245
|
+
startDateStr = format(startOfDay(clampedStart), 'yyyy-MM-dd');
|
|
1246
|
+
// Use endOfDay to include the full last day
|
|
1247
|
+
endDateStr = format(endOfDay(clampedEnd), 'yyyy-MM-dd');
|
|
1248
|
+
} else {
|
|
1249
|
+
// Standard products: API expects UTC. Get full first/last days in company TZ, then format as UTC.
|
|
1250
|
+
const startDateInTz = formatInTimeZone(clampedStart, companyTimezone, 'yyyy-MM-dd');
|
|
1251
|
+
const endDateInTz = formatInTimeZone(clampedEnd, companyTimezone, 'yyyy-MM-dd');
|
|
1252
|
+
const startMoment = fromZonedTime(parseISO(`${startDateInTz}T00:00:00.000`), companyTimezone);
|
|
1253
|
+
const endMoment = fromZonedTime(parseISO(`${endDateInTz}T23:59:59.999`), companyTimezone);
|
|
1254
|
+
startDateStr = formatInTimeZone(startMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
1255
|
+
endDateStr = formatInTimeZone(endMoment, 'UTC', "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Fetch availabilities for all product options in parallel (no currency param - we use precomputedPrices for display)
|
|
1259
|
+
const availabilityPromises = activeOptions.map(async (option) => {
|
|
1260
|
+
const result = await getAvailabilities(option.optionId, startDateStr, endDateStr, {
|
|
1261
|
+
promoCode: appliedPromoCode || undefined,
|
|
1262
|
+
...(pricingProfileIdForAvailabilities
|
|
1263
|
+
? { pricingProfileId: pricingProfileIdForAvailabilities }
|
|
1264
|
+
: {}),
|
|
1265
|
+
...(cancellationPolicyProfileIdForAvailabilities
|
|
1266
|
+
? { cancellationPolicyProfileId: cancellationPolicyProfileIdForAvailabilities }
|
|
1267
|
+
: {}),
|
|
1268
|
+
});
|
|
1269
|
+
// Store pricing config from first response only (all responses have same config)
|
|
1270
|
+
if (result.pricingConfig && !pricingConfigSetRef.current) {
|
|
1271
|
+
setPricingConfig(prev => {
|
|
1272
|
+
if (!prev) {
|
|
1273
|
+
pricingConfigSetRef.current = true;
|
|
1274
|
+
return result.pricingConfig!;
|
|
1275
|
+
}
|
|
1276
|
+
return prev;
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
// Tag each availability with its productOptionId and carry precomputedPrices for this option
|
|
1280
|
+
return {
|
|
1281
|
+
optionId: option.optionId,
|
|
1282
|
+
availabilities: result.availabilities.map(avail => ({
|
|
1283
|
+
...avail,
|
|
1284
|
+
productOptionId: option.optionId
|
|
1285
|
+
})),
|
|
1286
|
+
precomputedPrices: result.precomputedPrices,
|
|
1287
|
+
pricingConfig: result.pricingConfig,
|
|
1288
|
+
};
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
const results = await Promise.all(availabilityPromises);
|
|
1292
|
+
const allFetchedAvailabilities = results.flatMap(r => r.availabilities);
|
|
1293
|
+
if (allFetchedAvailabilities.length > 0) {
|
|
1294
|
+
hasLoadedAvailabilitiesRef.current = true;
|
|
1295
|
+
}
|
|
1296
|
+
setPrecomputedPricesByOption(prev => {
|
|
1297
|
+
const next = { ...(prev || {}) };
|
|
1298
|
+
results.forEach(r => {
|
|
1299
|
+
if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
|
|
1300
|
+
next[r.optionId] = r.precomputedPrices;
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
return Object.keys(next).length > 0 ? next : prev;
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
// Merge with existing availabilities (avoid duplicates by dateTime + productOptionId)
|
|
1307
|
+
setAvailabilities(prev => {
|
|
1308
|
+
const existingMap = new Map(
|
|
1309
|
+
prev.map(avail => [`${avail.dateTime}-${avail.productOptionId}`, avail])
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
// Merge new availabilities - update existing ones or add new ones
|
|
1313
|
+
allFetchedAvailabilities.forEach(avail => {
|
|
1314
|
+
const key = `${avail.dateTime}-${avail.productOptionId}`;
|
|
1315
|
+
// Always update to get latest data (vacancies, prices, etc.)
|
|
1316
|
+
existingMap.set(key, avail);
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
return Array.from(existingMap.values());
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
// Mark this range as fetched
|
|
1323
|
+
fetchedRangesRef.current.push({ start: new Date(clampedStart), end: new Date(clampedEnd) });
|
|
1324
|
+
// Sort and merge overlapping ranges
|
|
1325
|
+
fetchedRangesRef.current.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
1326
|
+
const merged: Array<{ start: Date; end: Date }> = [];
|
|
1327
|
+
for (const r of fetchedRangesRef.current) {
|
|
1328
|
+
if (merged.length === 0 || merged[merged.length - 1].end < r.start) {
|
|
1329
|
+
merged.push({ start: r.start, end: r.end });
|
|
1330
|
+
} else {
|
|
1331
|
+
merged[merged.length - 1].end = r.end > merged[merged.length - 1].end
|
|
1332
|
+
? r.end
|
|
1333
|
+
: merged[merged.length - 1].end;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
fetchedRangesRef.current = merged;
|
|
1337
|
+
|
|
1338
|
+
// Update cache for instant load when reopening same product
|
|
1339
|
+
if (cacheKey && availabilitiesCache) {
|
|
1340
|
+
const existingCache = availabilitiesCache.get(cacheKey);
|
|
1341
|
+
const existingAvailabilities = existingCache?.availabilities ?? [];
|
|
1342
|
+
const mergedAvailabilitiesMap = new Map(
|
|
1343
|
+
existingAvailabilities.map((a) => [`${a.dateTime}-${a.productOptionId}`, a])
|
|
1344
|
+
);
|
|
1345
|
+
allFetchedAvailabilities.forEach((a) => {
|
|
1346
|
+
mergedAvailabilitiesMap.set(`${a.dateTime}-${a.productOptionId}`, a);
|
|
1347
|
+
});
|
|
1348
|
+
const mergedPrecomputed = { ...(existingCache?.precomputedPricesByOption ?? {}) };
|
|
1349
|
+
results.forEach((r) => {
|
|
1350
|
+
if (r.precomputedPrices && Object.keys(r.precomputedPrices).length > 0) {
|
|
1351
|
+
mergedPrecomputed[r.optionId] = r.precomputedPrices;
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
const firstPricingConfig = (results[0] as { pricingConfig?: PricingConfig } | undefined)?.pricingConfig ?? existingCache?.pricingConfig ?? null;
|
|
1355
|
+
availabilitiesCache.merge(cacheKey, {
|
|
1356
|
+
fetchedRanges: merged,
|
|
1357
|
+
availabilities: Array.from(mergedAvailabilitiesMap.values()),
|
|
1358
|
+
pricingConfig: firstPricingConfig,
|
|
1359
|
+
precomputedPricesByOption: Object.keys(mergedPrecomputed).length > 0 ? mergedPrecomputed : null,
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Initialize quantities based on first availability's categories (only if not already set)
|
|
1364
|
+
// Use functional update to avoid dependency on quantities state
|
|
1365
|
+
const fetchedInitialQuantities = deriveDefaultQuantitiesFromAvailabilities(allFetchedAvailabilities);
|
|
1366
|
+
if (fetchedInitialQuantities) {
|
|
1367
|
+
setQuantities((prev) => (Object.keys(prev).length > 0 ? prev : fetchedInitialQuantities));
|
|
1368
|
+
}
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
setError(err instanceof Error ? err.message : 'Failed to load availabilities');
|
|
1371
|
+
console.error('Error fetching availabilities:', err);
|
|
1372
|
+
} finally {
|
|
1373
|
+
setLoadingAvailabilities(false);
|
|
1374
|
+
setIsFetchingMoreAvailabilities(false);
|
|
1375
|
+
fetchingRef.current = false;
|
|
1376
|
+
inFlightRangeRef.current = null;
|
|
1377
|
+
// If user navigated during fetch, trigger fetch for the pending range
|
|
1378
|
+
const pending = pendingRangeRef.current;
|
|
1379
|
+
if (pending) {
|
|
1380
|
+
pendingRangeRef.current = null;
|
|
1381
|
+
setVisibleRange({ start: pending.start, end: pending.end });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
fetchAvailabilities();
|
|
1387
|
+
// Use activeOptionIdsKey only — activeOptions is a new array reference when product.options identity changes and would refetch in a tight loop.
|
|
1388
|
+
}, [
|
|
1389
|
+
isPartialLaunch,
|
|
1390
|
+
visibleRange,
|
|
1391
|
+
activeOptionIdsKey,
|
|
1392
|
+
isPrivateShuttle,
|
|
1393
|
+
companyTimezone,
|
|
1394
|
+
selectedDate,
|
|
1395
|
+
appliedPromoCode,
|
|
1396
|
+
pricingProfileIdForAvailabilities,
|
|
1397
|
+
cancellationPolicyProfileIdForAvailabilities,
|
|
1398
|
+
]);
|
|
1399
|
+
|
|
1400
|
+
// When promo or partner pricing profile changes, clear fetched ranges so we refetch with new pricing
|
|
1401
|
+
useEffect(() => {
|
|
1402
|
+
fetchedRangesRef.current = [];
|
|
1403
|
+
}, [
|
|
1404
|
+
appliedPromoCode,
|
|
1405
|
+
pricingProfileIdForAvailabilities,
|
|
1406
|
+
cancellationPolicyProfileIdForAvailabilities,
|
|
1407
|
+
]);
|
|
1408
|
+
|
|
1409
|
+
// Memoized callback for visible range changes
|
|
1410
|
+
// Only update if the range actually changed to avoid unnecessary fetches
|
|
1411
|
+
const lastVisibleRangeRef = useRef<{ start: Date; end: Date } | null>(null);
|
|
1412
|
+
const handleVisibleRangeChange = useCallback((start: Date, end: Date) => {
|
|
1413
|
+
const lastRange = lastVisibleRangeRef.current;
|
|
1414
|
+
// Update if this is the first range or if it changed significantly (more than a day)
|
|
1415
|
+
const rangeChanged = !lastRange ||
|
|
1416
|
+
Math.abs(lastRange.start.getTime() - start.getTime()) > 24 * 60 * 60 * 1000 ||
|
|
1417
|
+
Math.abs(lastRange.end.getTime() - end.getTime()) > 24 * 60 * 60 * 1000;
|
|
1418
|
+
|
|
1419
|
+
if (rangeChanged) {
|
|
1420
|
+
lastVisibleRangeRef.current = { start, end };
|
|
1421
|
+
// Always update state to trigger fetch, even if needsFetch might return false
|
|
1422
|
+
// The needsFetch check will prevent unnecessary API calls, but we want the state update
|
|
1423
|
+
setVisibleRange({ start, end });
|
|
1424
|
+
}
|
|
1425
|
+
}, []);
|
|
1426
|
+
|
|
1427
|
+
// Group availabilities by date (in company timezone) and sort by time
|
|
1428
|
+
// Memoized to prevent recalculation on every render
|
|
1429
|
+
const availabilitiesByDate = useMemo(() => {
|
|
1430
|
+
const grouped = availabilities.reduce((acc, avail) => {
|
|
1431
|
+
// Parse the dateTime and extract the date in company timezone
|
|
1432
|
+
const dateTime = parseAvailabilityDateTime(avail.dateTime);
|
|
1433
|
+
const dateInCompanyTz = formatInTimeZone(dateTime, companyTimezone, 'yyyy-MM-dd');
|
|
1434
|
+
if (!acc[dateInCompanyTz]) acc[dateInCompanyTz] = [];
|
|
1435
|
+
acc[dateInCompanyTz].push(avail);
|
|
1436
|
+
return acc;
|
|
1437
|
+
}, {} as Record<string, Availability[]>);
|
|
1438
|
+
|
|
1439
|
+
// Sort availabilities within each date by time (create new sorted arrays, don't mutate)
|
|
1440
|
+
const sorted: Record<string, Availability[]> = {};
|
|
1441
|
+
Object.keys(grouped).forEach(date => {
|
|
1442
|
+
sorted[date] = [...grouped[date]].sort((a, b) => {
|
|
1443
|
+
const timeA = parseAvailabilityDateTime(a.dateTime).getTime();
|
|
1444
|
+
const timeB = parseAvailabilityDateTime(b.dateTime).getTime();
|
|
1445
|
+
return timeA - timeB;
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
return sorted;
|
|
1450
|
+
}, [availabilities, companyTimezone]);
|
|
1451
|
+
|
|
1452
|
+
const dates = useMemo(() => Object.keys(availabilitiesByDate).sort(), [availabilitiesByDate]);
|
|
1453
|
+
|
|
1454
|
+
// Track mobile state
|
|
1455
|
+
useEffect(() => {
|
|
1456
|
+
const checkMobile = () => {
|
|
1457
|
+
setIsMobile(window.innerWidth < 640); // sm breakpoint
|
|
1458
|
+
};
|
|
1459
|
+
checkMobile();
|
|
1460
|
+
window.addEventListener('resize', checkMobile);
|
|
1461
|
+
return () => window.removeEventListener('resize', checkMobile);
|
|
1462
|
+
}, []);
|
|
1463
|
+
|
|
1464
|
+
// Close tooltip when clicking outside
|
|
1465
|
+
useEffect(() => {
|
|
1466
|
+
if (!showTooltip) return;
|
|
1467
|
+
|
|
1468
|
+
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
|
|
1469
|
+
const target = e.target as HTMLElement;
|
|
1470
|
+
if (!target.closest('[data-tooltip-icon]')) {
|
|
1471
|
+
setShowTooltip(false);
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
|
|
1475
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
1476
|
+
document.addEventListener('touchstart', handleClickOutside);
|
|
1477
|
+
|
|
1478
|
+
return () => {
|
|
1479
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
1480
|
+
document.removeEventListener('touchstart', handleClickOutside);
|
|
1481
|
+
};
|
|
1482
|
+
}, [showTooltip]);
|
|
1483
|
+
|
|
1484
|
+
// Detect when itinerary box becomes sticky
|
|
1485
|
+
// In viavia the scroll usually happens inside the dialog content div, not window.
|
|
1486
|
+
// On full-page partner layouts, we instead listen to window scroll (useWindowScroll = true).
|
|
1487
|
+
const lastStickyChangeRef = useRef<number>(0);
|
|
1488
|
+
useEffect(() => {
|
|
1489
|
+
const el = itineraryRef.current;
|
|
1490
|
+
if (!el) return;
|
|
1491
|
+
|
|
1492
|
+
const findScrollParent = (node: HTMLElement): HTMLElement | null => {
|
|
1493
|
+
let parent = node.parentElement;
|
|
1494
|
+
while (parent) {
|
|
1495
|
+
const { overflowY } = getComputedStyle(parent);
|
|
1496
|
+
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') return parent;
|
|
1497
|
+
parent = parent.parentElement;
|
|
1498
|
+
}
|
|
1499
|
+
return null;
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
const scrollParent = findScrollParent(el);
|
|
1503
|
+
const scrollTarget =
|
|
1504
|
+
useWindowScroll || !scrollParent
|
|
1505
|
+
? (typeof window !== 'undefined' ? window : null)
|
|
1506
|
+
: scrollParent;
|
|
1507
|
+
|
|
1508
|
+
let ticking = false;
|
|
1509
|
+
const COOLDOWN_MS = 600; // After a state change, ignore reverse changes for this long (covers 0.25s collapse animation + layout settle)
|
|
1510
|
+
const atTopBand = 48; // px - must scroll back up past this band to expand again (wider = less oscillation at edges)
|
|
1511
|
+
|
|
1512
|
+
const updateStickyState = () => {
|
|
1513
|
+
if (!itineraryRef.current) return;
|
|
1514
|
+
|
|
1515
|
+
const rect = itineraryRef.current.getBoundingClientRect();
|
|
1516
|
+
const currentTop = rect.top;
|
|
1517
|
+
const wasSticky = isItineraryStickyRef.current;
|
|
1518
|
+
|
|
1519
|
+
const containerTop =
|
|
1520
|
+
scrollParent && !useWindowScroll ? scrollParent.getBoundingClientRect().top : 0;
|
|
1521
|
+
const topInset = Math.max(0, flowUi?.itineraryStickyTopOffsetPx ?? 0);
|
|
1522
|
+
const stickLine = containerTop + topInset;
|
|
1523
|
+
const enterStickyThreshold = stickLine + 1;
|
|
1524
|
+
const nextSticky = wasSticky
|
|
1525
|
+
? currentTop >= stickLine - atTopBand && currentTop <= stickLine + atTopBand
|
|
1526
|
+
: currentTop <= enterStickyThreshold;
|
|
1527
|
+
|
|
1528
|
+
if (nextSticky !== wasSticky) {
|
|
1529
|
+
const now = Date.now();
|
|
1530
|
+
if (now - lastStickyChangeRef.current < COOLDOWN_MS) return; // Cooldown: prevent rapid toggling
|
|
1531
|
+
|
|
1532
|
+
lastStickyChangeRef.current = now;
|
|
1533
|
+
isItineraryStickyRef.current = nextSticky;
|
|
1534
|
+
setIsItinerarySticky(nextSticky);
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
const handleScroll = () => {
|
|
1539
|
+
if (!ticking) {
|
|
1540
|
+
window.requestAnimationFrame(() => {
|
|
1541
|
+
updateStickyState();
|
|
1542
|
+
ticking = false;
|
|
1543
|
+
});
|
|
1544
|
+
ticking = true;
|
|
1545
|
+
}
|
|
1546
|
+
};
|
|
1547
|
+
|
|
1548
|
+
if (scrollTarget) {
|
|
1549
|
+
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
|
|
1550
|
+
updateStickyState();
|
|
1551
|
+
return () => scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1552
|
+
}
|
|
1553
|
+
return undefined;
|
|
1554
|
+
}, [selectedDate, selectedAvailability, useWindowScroll, flowUi?.itineraryStickyTopOffsetPx]); // Re-check when itinerary / scroll mode / host chrome changes
|
|
1555
|
+
|
|
1556
|
+
// Find the earliest availability date - memoize with a stable reference
|
|
1557
|
+
// Only recalculate if we don't have a cached value or if the new earliest is actually earlier
|
|
1558
|
+
// IMPORTANT: Never return null once we have a value, to prevent calendar reset during loading
|
|
1559
|
+
const earliestAvailabilityDateRef = useRef<Date | null>(null);
|
|
1560
|
+
const earliestAvailabilityDate = useMemo(() => {
|
|
1561
|
+
if (dates.length === 0) {
|
|
1562
|
+
// If we have a cached value, keep using it even during loading
|
|
1563
|
+
return earliestAvailabilityDateRef.current || EARLIEST_AVAILABILITY_DATE;
|
|
1564
|
+
}
|
|
1565
|
+
const firstDate = dates[0];
|
|
1566
|
+
const firstAvail = availabilitiesByDate[firstDate]?.[0];
|
|
1567
|
+
let newEarliest: Date;
|
|
1568
|
+
if (firstAvail) {
|
|
1569
|
+
newEarliest = parseISO(firstAvail.dateTime);
|
|
1570
|
+
} else {
|
|
1571
|
+
// Fallback: parse the date string
|
|
1572
|
+
// Use company timezone noon to avoid one-day shifts for users in other timezones.
|
|
1573
|
+
newEarliest = fromZonedTime(parseISO(`${firstDate}T12:00:00`), companyTimezone);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Only update if we don't have a cached value or if the new one is earlier
|
|
1577
|
+
if (!earliestAvailabilityDateRef.current || newEarliest < earliestAvailabilityDateRef.current) {
|
|
1578
|
+
earliestAvailabilityDateRef.current = newEarliest;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
return earliestAvailabilityDateRef.current;
|
|
1582
|
+
}, [dates, availabilitiesByDate]);
|
|
1583
|
+
|
|
1584
|
+
const timesForSelectedDate = useMemo(() => {
|
|
1585
|
+
return selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
|
|
1586
|
+
}, [selectedDate, availabilitiesByDate]);
|
|
1587
|
+
const timesForSelectedDateSelectionKey = useMemo(
|
|
1588
|
+
() =>
|
|
1589
|
+
timesForSelectedDate
|
|
1590
|
+
.map(
|
|
1591
|
+
(avail) =>
|
|
1592
|
+
`${avail.dateTime}|${avail.productOptionId ?? ''}|${avail.vacancies ?? 0}`,
|
|
1593
|
+
)
|
|
1594
|
+
.join('||'),
|
|
1595
|
+
[timesForSelectedDate],
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Bookings often store only parent `p_…` (no `po_…`). Resolve the booked option id from inventory rows,
|
|
1600
|
+
* wall-clock match, or single active option — must be defined before effects that seed selection / seat credit.
|
|
1601
|
+
*/
|
|
1602
|
+
const changeFlowResolvedInitialProductOptionId = useMemo((): string | null => {
|
|
1603
|
+
const fromFields = effectiveProductOptionIdForChangeFlow({
|
|
1604
|
+
productId: initialValues?.productId,
|
|
1605
|
+
productOptionId: initialValues?.productOptionId,
|
|
1606
|
+
});
|
|
1607
|
+
if (fromFields) return fromFields;
|
|
1608
|
+
|
|
1609
|
+
const aid = initialValues?.availabilityId?.trim();
|
|
1610
|
+
if (aid) {
|
|
1611
|
+
for (const a of availabilities) {
|
|
1612
|
+
if (a.availabilityId === aid) {
|
|
1613
|
+
const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
|
|
1614
|
+
if (po) return po;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
if (initialValues?.dateTime?.trim()) {
|
|
1620
|
+
try {
|
|
1621
|
+
const initialDt = parseAvailabilityDateTime(initialValues.dateTime.trim());
|
|
1622
|
+
const initialMs = initialDt.getTime();
|
|
1623
|
+
const initialDay = formatInTimeZone(initialDt, companyTimezone, 'yyyy-MM-dd');
|
|
1624
|
+
for (const a of availabilities) {
|
|
1625
|
+
const avDt = parseAvailabilityDateTime(a.dateTime);
|
|
1626
|
+
if (formatInTimeZone(avDt, companyTimezone, 'yyyy-MM-dd') !== initialDay) continue;
|
|
1627
|
+
if (avDt.getTime() !== initialMs) continue;
|
|
1628
|
+
const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
|
|
1629
|
+
if (po) return po;
|
|
1630
|
+
}
|
|
1631
|
+
} catch {
|
|
1632
|
+
/* ignore */
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (activeOptions.length === 1) {
|
|
1637
|
+
return normalizeProductOptionIdForChangeFlow(activeOptions[0].optionId);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
return null;
|
|
1641
|
+
}, [
|
|
1642
|
+
initialValues?.productId,
|
|
1643
|
+
initialValues?.productOptionId,
|
|
1644
|
+
initialValues?.availabilityId,
|
|
1645
|
+
initialValues?.dateTime,
|
|
1646
|
+
availabilities,
|
|
1647
|
+
activeOptions,
|
|
1648
|
+
companyTimezone,
|
|
1649
|
+
]);
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Parent catalog product id (`p_…`) for minimum-paid receipt floors: explicit parent on the booking,
|
|
1653
|
+
* otherwise the product loaded for this change session (booking payload may only carry `po_…`).
|
|
1654
|
+
*/
|
|
1655
|
+
const changeFlowBookingParentProductIdForFloors = useMemo(() => {
|
|
1656
|
+
const pid = initialValues?.productId?.trim();
|
|
1657
|
+
if (pid && isParentProductId(pid)) return pid;
|
|
1658
|
+
return product.productId.trim();
|
|
1659
|
+
}, [initialValues?.productId, product.productId]);
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Receipt pricing on protected seats/fees: Rule A (exact receipt unit when same calendar day
|
|
1663
|
+
* + same product option) vs Rule B (`max(receipt, live)` when date or option changes).
|
|
1664
|
+
*/
|
|
1665
|
+
const changeFlowApplyReceiptPaidFloors = useMemo(
|
|
1666
|
+
() => changeFlowBookingParentProductIdForFloors === product.productId.trim(),
|
|
1667
|
+
[changeFlowBookingParentProductIdForFloors, product.productId],
|
|
1668
|
+
);
|
|
1669
|
+
|
|
1670
|
+
useEffect(() => {
|
|
1671
|
+
if (hasAppliedInitialValuesRef.current || !initialValues) return;
|
|
1672
|
+
const trimmedEmail = initialValues.customer?.email?.trim();
|
|
1673
|
+
const trimmedFirstName = initialValues.customer?.firstName?.trim();
|
|
1674
|
+
const trimmedLastName = initialValues.customer?.lastName?.trim();
|
|
1675
|
+
const trimmedPromo = initialValues.promoCode?.trim().toUpperCase();
|
|
1676
|
+
if (trimmedEmail) setEmail(trimmedEmail);
|
|
1677
|
+
if (trimmedFirstName) setFirstName(trimmedFirstName);
|
|
1678
|
+
if (trimmedLastName) setLastName(trimmedLastName);
|
|
1679
|
+
if (initialValues.pickupLocationId?.trim()) {
|
|
1680
|
+
setPickupLocationId(initialValues.pickupLocationId.trim());
|
|
1681
|
+
setPickupLocationSkipped(false);
|
|
1682
|
+
}
|
|
1683
|
+
if (trimmedPromo) {
|
|
1684
|
+
setPromoCodeInput(trimmedPromo);
|
|
1685
|
+
setAppliedPromoCode(trimmedPromo);
|
|
1686
|
+
}
|
|
1687
|
+
hasAppliedInitialValuesRef.current = true;
|
|
1688
|
+
}, [initialValues]);
|
|
1689
|
+
|
|
1690
|
+
useEffect(() => {
|
|
1691
|
+
if (!lockedPromoCode) return;
|
|
1692
|
+
if (appliedPromoCode !== lockedPromoCode) setAppliedPromoCode(lockedPromoCode);
|
|
1693
|
+
if (promoCodeInput !== lockedPromoCode) setPromoCodeInput(lockedPromoCode);
|
|
1694
|
+
}, [lockedPromoCode, appliedPromoCode, promoCodeInput]);
|
|
1695
|
+
|
|
1696
|
+
useEffect(() => {
|
|
1697
|
+
if (!initialValues?.dateTime || selectedAvailability) return;
|
|
1698
|
+
const target = parseAvailabilityDateTime(initialValues.dateTime);
|
|
1699
|
+
const targetDate = formatInTimeZone(target, companyTimezone, 'yyyy-MM-dd');
|
|
1700
|
+
if (!selectedDate) {
|
|
1701
|
+
setSelectedDate(targetDate);
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
if (selectedDate !== targetDate || timesForSelectedDate.length === 0) return;
|
|
1705
|
+
const { selection, defer } = resolveInitialAvailabilityFromBooking(
|
|
1706
|
+
timesForSelectedDate,
|
|
1707
|
+
target,
|
|
1708
|
+
companyTimezone,
|
|
1709
|
+
initialValues.availabilityId ?? null,
|
|
1710
|
+
changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
|
|
1711
|
+
initialValues.bookingItems ?? null,
|
|
1712
|
+
precomputedPricesByOption,
|
|
1713
|
+
currency,
|
|
1714
|
+
originalReceipt?.subtotal
|
|
1715
|
+
);
|
|
1716
|
+
if (defer) return;
|
|
1717
|
+
if (selection) {
|
|
1718
|
+
setSelectedAvailability(selection);
|
|
1719
|
+
setError('');
|
|
1720
|
+
}
|
|
1721
|
+
}, [
|
|
1722
|
+
initialValues?.dateTime,
|
|
1723
|
+
initialValues?.availabilityId,
|
|
1724
|
+
initialValues?.productOptionId,
|
|
1725
|
+
initialValues?.bookingItems,
|
|
1726
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1727
|
+
selectedAvailability,
|
|
1728
|
+
selectedDate,
|
|
1729
|
+
timesForSelectedDate,
|
|
1730
|
+
companyTimezone,
|
|
1731
|
+
precomputedPricesByOption,
|
|
1732
|
+
currency,
|
|
1733
|
+
originalReceipt?.subtotal,
|
|
1734
|
+
]);
|
|
1735
|
+
|
|
1736
|
+
useEffect(() => {
|
|
1737
|
+
if (
|
|
1738
|
+
hasAppliedInitialQuantitiesRef.current ||
|
|
1739
|
+
!initialValues?.bookingItems ||
|
|
1740
|
+
initialValues.bookingItems.length === 0 ||
|
|
1741
|
+
!selectedAvailability
|
|
1742
|
+
) {
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
const next: Record<string, number> = {};
|
|
1746
|
+
for (const item of initialValues.bookingItems) {
|
|
1747
|
+
const key = item.category?.trim();
|
|
1748
|
+
if (!key) continue;
|
|
1749
|
+
next[key] = Math.max(0, Number(item.count) || 0);
|
|
1750
|
+
}
|
|
1751
|
+
if (Object.keys(next).length > 0) {
|
|
1752
|
+
setQuantities((prev) => ({ ...prev, ...next }));
|
|
1753
|
+
hasAppliedInitialQuantitiesRef.current = true;
|
|
1754
|
+
}
|
|
1755
|
+
}, [initialValues, selectedAvailability]);
|
|
1756
|
+
|
|
1757
|
+
useEffect(() => {
|
|
1758
|
+
if (!initialValues?.addOnSelections?.length) return;
|
|
1759
|
+
setAddOnSelections((prev) => (prev.length > 0 ? prev : normalizeAddOnSelections(initialValues.addOnSelections!)));
|
|
1760
|
+
}, [initialValues?.addOnSelections]);
|
|
1761
|
+
|
|
1762
|
+
useEffect(() => {
|
|
1763
|
+
if (hasHydratedAddOnsFromReceiptRef.current) return;
|
|
1764
|
+
if ((initialValues?.addOnSelections?.length ?? 0) > 0) return;
|
|
1765
|
+
if (addOnSelections.length > 0) return;
|
|
1766
|
+
if (addOns.length === 0) return;
|
|
1767
|
+
const receiptLines = originalReceipt?.lineItems ?? [];
|
|
1768
|
+
if (receiptLines.length === 0) return;
|
|
1769
|
+
const derived = deriveAddOnSelectionsFromReceiptLines(addOns, receiptLines);
|
|
1770
|
+
hasHydratedAddOnsFromReceiptRef.current = true;
|
|
1771
|
+
if (derived.length > 0) {
|
|
1772
|
+
setAddOnSelections(normalizeAddOnSelections(derived));
|
|
1773
|
+
}
|
|
1774
|
+
}, [
|
|
1775
|
+
initialValues?.addOnSelections,
|
|
1776
|
+
addOnSelections.length,
|
|
1777
|
+
addOns,
|
|
1778
|
+
originalReceipt?.lineItems,
|
|
1779
|
+
]);
|
|
1780
|
+
|
|
1781
|
+
const initialAddOnBaselineSelections = useMemo(() => {
|
|
1782
|
+
if ((initialValues?.addOnSelections?.length ?? 0) > 0) {
|
|
1783
|
+
return normalizeAddOnSelections(initialValues!.addOnSelections!);
|
|
1784
|
+
}
|
|
1785
|
+
if ((originalReceipt?.lineItems?.length ?? 0) > 0 && addOns.length > 0) {
|
|
1786
|
+
return normalizeAddOnSelections(deriveAddOnSelectionsFromReceiptLines(addOns, originalReceipt!.lineItems!));
|
|
1787
|
+
}
|
|
1788
|
+
return [] as Array<{ addOnId: string; variantId?: string; quantity?: number }>;
|
|
1789
|
+
}, [initialValues?.addOnSelections, originalReceipt?.lineItems, addOns]);
|
|
1790
|
+
|
|
1791
|
+
const initialAddOnMinQtyByKey = useMemo(() => {
|
|
1792
|
+
const map = new Map<string, number>();
|
|
1793
|
+
if (false) return map;
|
|
1794
|
+
for (const sel of initialAddOnBaselineSelections) {
|
|
1795
|
+
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
1796
|
+
map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1797
|
+
}
|
|
1798
|
+
return map;
|
|
1799
|
+
}, [true, initialAddOnBaselineSelections]);
|
|
1800
|
+
|
|
1801
|
+
const initialAddOnMinTotalByAddOnId = useMemo(() => {
|
|
1802
|
+
const map = new Map<string, number>();
|
|
1803
|
+
if (false) return map;
|
|
1804
|
+
for (const sel of initialAddOnBaselineSelections) {
|
|
1805
|
+
const addOnId = sel.addOnId.trim();
|
|
1806
|
+
map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1807
|
+
}
|
|
1808
|
+
return map;
|
|
1809
|
+
}, [true, initialAddOnBaselineSelections]);
|
|
1810
|
+
|
|
1811
|
+
const applyChangeFlowAddOnFloor = useCallback(
|
|
1812
|
+
(nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => {
|
|
1813
|
+
if (false || initialAddOnMinQtyByKey.size === 0) return nextSelections;
|
|
1814
|
+
const qtyByKey = new Map<string, number>();
|
|
1815
|
+
for (const sel of nextSelections) {
|
|
1816
|
+
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
1817
|
+
qtyByKey.set(key, (qtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const totalByAddOnId = new Map<string, number>();
|
|
1821
|
+
for (const [key, qty] of qtyByKey.entries()) {
|
|
1822
|
+
const addOnId = key.split('::')[0];
|
|
1823
|
+
totalByAddOnId.set(addOnId, (totalByAddOnId.get(addOnId) ?? 0) + qty);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
for (const [addOnId, minTotal] of initialAddOnMinTotalByAddOnId.entries()) {
|
|
1827
|
+
const currentTotal = totalByAddOnId.get(addOnId) ?? 0;
|
|
1828
|
+
if (currentTotal >= minTotal) continue;
|
|
1829
|
+
const deficit = minTotal - currentTotal;
|
|
1830
|
+
const existingKey = Array.from(qtyByKey.keys()).find((k) => k.startsWith(`${addOnId}::`));
|
|
1831
|
+
const targetKey = existingKey ?? `${addOnId}::`;
|
|
1832
|
+
qtyByKey.set(targetKey, (qtyByKey.get(targetKey) ?? 0) + deficit);
|
|
1833
|
+
}
|
|
1834
|
+
return Array.from(qtyByKey.entries()).map(([key, qty]) => {
|
|
1835
|
+
const [addOnId, variantIdRaw] = key.split('::');
|
|
1836
|
+
const variantId = variantIdRaw?.trim() || undefined;
|
|
1837
|
+
return { addOnId, variantId, quantity: qty };
|
|
1838
|
+
});
|
|
1839
|
+
},
|
|
1840
|
+
[true, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
|
|
1841
|
+
);
|
|
1842
|
+
|
|
1843
|
+
const updateAddOnSelections = useCallback(
|
|
1844
|
+
(
|
|
1845
|
+
updater:
|
|
1846
|
+
| Array<{ addOnId: string; variantId?: string; quantity?: number }>
|
|
1847
|
+
| ((prev: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => Array<{ addOnId: string; variantId?: string; quantity?: number }>)
|
|
1848
|
+
) => {
|
|
1849
|
+
setAddOnSelections((prev) => {
|
|
1850
|
+
const rawNext = typeof updater === 'function' ? updater(prev) : updater;
|
|
1851
|
+
return applyChangeFlowAddOnFloor(rawNext);
|
|
1852
|
+
});
|
|
1853
|
+
},
|
|
1854
|
+
[applyChangeFlowAddOnFloor]
|
|
1855
|
+
);
|
|
1856
|
+
|
|
1857
|
+
// Get selected pickup location
|
|
1858
|
+
const selectedPickupLocation = useMemo(() =>
|
|
1859
|
+
product.pickupLocations?.find(loc => loc.id === pickupLocationId),
|
|
1860
|
+
[product.pickupLocations, pickupLocationId]
|
|
1861
|
+
);
|
|
1862
|
+
|
|
1863
|
+
// Calculate maximum time offset from all pickup locations (for range display when location is unknown)
|
|
1864
|
+
const maxTimeOffsetMinutes = useMemo(() => {
|
|
1865
|
+
if (!product.pickupLocations || product.pickupLocations.length === 0) {
|
|
1866
|
+
return 0;
|
|
1867
|
+
}
|
|
1868
|
+
return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
|
|
1869
|
+
}, [product.pickupLocations]);
|
|
1870
|
+
const changeFlowInitialTicketCountForSeatCredit = useMemo(() => {
|
|
1871
|
+
if (!initialValues?.bookingItems?.length) return 0;
|
|
1872
|
+
return initialValues.bookingItems.reduce(
|
|
1873
|
+
(sum, item) => sum + Math.max(0, Number(item.count) || 0),
|
|
1874
|
+
0,
|
|
1875
|
+
);
|
|
1876
|
+
}, [initialValues?.bookingItems]);
|
|
1877
|
+
|
|
1878
|
+
const changeFlowSeatCreditForOutboundAvailability = useCallback(
|
|
1879
|
+
(availability: Availability): number => {
|
|
1880
|
+
if (changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1881
|
+
const initialAvailabilityId = initialValues?.availabilityId?.trim();
|
|
1882
|
+
const availabilityId = availability.availabilityId?.trim();
|
|
1883
|
+
if (initialAvailabilityId && availabilityId) {
|
|
1884
|
+
return initialAvailabilityId === availabilityId ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1885
|
+
}
|
|
1886
|
+
if (!initialValues?.dateTime) return 0;
|
|
1887
|
+
const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
|
|
1888
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
1889
|
+
if (selectedMs !== initialMs) return 0;
|
|
1890
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
|
|
1891
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
1892
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
1893
|
+
return selectedOpt === initialOpt ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1894
|
+
}
|
|
1895
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
1896
|
+
const legacySelected = availability.productOptionId?.trim() || null;
|
|
1897
|
+
if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCountForSeatCredit;
|
|
1898
|
+
return legacySelected === legacyInitial ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1899
|
+
},
|
|
1900
|
+
[
|
|
1901
|
+
changeFlowInitialTicketCountForSeatCredit,
|
|
1902
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1903
|
+
initialValues?.availabilityId,
|
|
1904
|
+
initialValues?.dateTime,
|
|
1905
|
+
initialValues?.productOptionId,
|
|
1906
|
+
],
|
|
1907
|
+
);
|
|
1908
|
+
const changeFlowSeatCreditForReturnAvailabilityId = useCallback(
|
|
1909
|
+
(returnAvailabilityId: string | null | undefined): number => {
|
|
1910
|
+
if (changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1911
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
1912
|
+
const selectedReturnAvailabilityId = returnAvailabilityId?.trim() || null;
|
|
1913
|
+
if (!selectedReturnAvailabilityId) {
|
|
1914
|
+
return initialReturnAvailabilityId == null ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1915
|
+
}
|
|
1916
|
+
if (!initialReturnAvailabilityId) return 0;
|
|
1917
|
+
return selectedReturnAvailabilityId === initialReturnAvailabilityId
|
|
1918
|
+
? changeFlowInitialTicketCountForSeatCredit
|
|
1919
|
+
: 0;
|
|
1920
|
+
},
|
|
1921
|
+
[changeFlowInitialTicketCountForSeatCredit, initialValues?.returnAvailabilityId],
|
|
1922
|
+
);
|
|
1923
|
+
|
|
1924
|
+
/** Calendar sold-out / time pills: same effective outbound vacancies as pickup tiles (seat credit on original slot). */
|
|
1925
|
+
const getCalendarEffectiveOutboundVacancies = useCallback(
|
|
1926
|
+
(avail: Availability) =>
|
|
1927
|
+
Math.max(0, avail.vacancies ?? 0) + changeFlowSeatCreditForOutboundAvailability(avail),
|
|
1928
|
+
[changeFlowSeatCreditForOutboundAvailability],
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
// Calculate pickup times based on availability times + pickup location offset
|
|
1932
|
+
interface PickupTimeInfo extends Availability {
|
|
1933
|
+
pickupTime: string;
|
|
1934
|
+
displayTime: string;
|
|
1935
|
+
originalTime: string;
|
|
1936
|
+
displayTimeRange?: string; // Time range when pickup location is unknown
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
const pickupTimes = useMemo((): PickupTimeInfo[] => {
|
|
1940
|
+
if (!selectedDate || !timesForSelectedDate.length) {
|
|
1941
|
+
return [];
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Show base availability times (without pickup location offset) when pickup location not selected yet
|
|
1945
|
+
// Once pickup location is selected, we can show adjusted times
|
|
1946
|
+
const offsetMinutes = pickupLocationSkipped
|
|
1947
|
+
? 0
|
|
1948
|
+
: (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
1949
|
+
|
|
1950
|
+
return timesForSelectedDate.map(avail => {
|
|
1951
|
+
// Parse the dateTime (which should already be in company timezone from backend)
|
|
1952
|
+
const availabilityTime = parseISO(avail.dateTime);
|
|
1953
|
+
const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
|
|
1954
|
+
const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
|
|
1955
|
+
|
|
1956
|
+
// Only apply offset if it's set and > 0 and location is selected
|
|
1957
|
+
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
1958
|
+
? new Date(availabilityTime.getTime() + offsetMinutes * 60 * 1000)
|
|
1959
|
+
: availabilityTime;
|
|
1960
|
+
|
|
1961
|
+
// Format in company timezone (not user's local timezone)
|
|
1962
|
+
const displayTime = formatInTimeZone(pickupTime, companyTimezone, 'h:mm a');
|
|
1963
|
+
const originalTime = formatInTimeZone(availabilityTime, companyTimezone, 'h:mm a');
|
|
1964
|
+
|
|
1965
|
+
// If pickup location is skipped, calculate and display time range
|
|
1966
|
+
let displayTimeRange: string | undefined;
|
|
1967
|
+
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
1968
|
+
const latestPickupTime = new Date(availabilityTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
|
|
1969
|
+
const latestTimeStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
1970
|
+
displayTimeRange = `${originalTime} - ${latestTimeStr}`;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
return {
|
|
1974
|
+
...avail,
|
|
1975
|
+
vacancies: adjustedVacancies,
|
|
1976
|
+
pickupTime: pickupTime.toISOString(),
|
|
1977
|
+
displayTime,
|
|
1978
|
+
originalTime,
|
|
1979
|
+
displayTimeRange,
|
|
1980
|
+
};
|
|
1981
|
+
});
|
|
1982
|
+
}, [
|
|
1983
|
+
selectedDate,
|
|
1984
|
+
selectedPickupLocation,
|
|
1985
|
+
timesForSelectedDate,
|
|
1986
|
+
pickupLocationSkipped,
|
|
1987
|
+
maxTimeOffsetMinutes,
|
|
1988
|
+
companyTimezone,
|
|
1989
|
+
changeFlowSeatCreditForOutboundAvailability,
|
|
1990
|
+
]);
|
|
1991
|
+
|
|
1992
|
+
// Check if any pickup time has "most popular" tag (memoized for performance)
|
|
1993
|
+
const hasAnyMostPopular = useMemo(() => {
|
|
1994
|
+
if (pickupTimes.length <= 1) return false;
|
|
1995
|
+
return pickupTimes.some(t => {
|
|
1996
|
+
if (!t.productOptionId) return false;
|
|
1997
|
+
const opt = optionsMap.get(t.productOptionId);
|
|
1998
|
+
return opt?.mostPopular;
|
|
1999
|
+
});
|
|
2000
|
+
}, [pickupTimes, optionsMap]);
|
|
2001
|
+
|
|
2002
|
+
// Helper function to get effective itinerary based on selected date and overrides
|
|
2003
|
+
const getEffectiveItinerary = useCallback((option: typeof activeOptions[0], date: Date): typeof option.itinerary => {
|
|
2004
|
+
if (!option.itinerary) return undefined;
|
|
2005
|
+
|
|
2006
|
+
const monthDay = formatInTimeZone(date, companyTimezone, 'MM-dd');
|
|
2007
|
+
|
|
2008
|
+
// Check for date-specific override
|
|
2009
|
+
if (option.itineraryOverrides && option.itineraryOverrides.length > 0) {
|
|
2010
|
+
for (const override of option.itineraryOverrides) {
|
|
2011
|
+
if (monthDay >= override.startDate && monthDay <= override.endDate) {
|
|
2012
|
+
return override.itinerary;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
return option.itinerary;
|
|
2018
|
+
}, [companyTimezone]);
|
|
2019
|
+
|
|
2020
|
+
// Helper function to calculate stay summary for a specific return option
|
|
2021
|
+
const calculateStaySummary = useCallback((returnDateTime: Date): string | null => {
|
|
2022
|
+
if (!selectedAvailability) return null;
|
|
2023
|
+
|
|
2024
|
+
const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
|
|
2025
|
+
const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
|
|
2026
|
+
if (!selectedOption) return null;
|
|
2027
|
+
|
|
2028
|
+
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
2029
|
+
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
2030
|
+
const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
2031
|
+
|
|
2032
|
+
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
2033
|
+
|
|
2034
|
+
const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
|
|
2035
|
+
let lastDepartureTime = tourStartTime;
|
|
2036
|
+
|
|
2037
|
+
const stays: Array<{ destinationName: string; durationHours: number }> = [];
|
|
2038
|
+
|
|
2039
|
+
itinerary.forEach((itineraryItem, index) => {
|
|
2040
|
+
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
2041
|
+
if (!destination) return;
|
|
2042
|
+
|
|
2043
|
+
const arrivalTime = new Date(
|
|
2044
|
+
lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000)
|
|
2045
|
+
);
|
|
2046
|
+
|
|
2047
|
+
const isLastDestination = index === itinerary.length - 1;
|
|
2048
|
+
|
|
2049
|
+
if (isLastDestination) {
|
|
2050
|
+
// For the last destination, calculate time from arrival to return time
|
|
2051
|
+
const timeDiffMs = returnDateTime.getTime() - arrivalTime.getTime();
|
|
2052
|
+
const timeDiffHours = timeDiffMs / (1000 * 60 * 60);
|
|
2053
|
+
|
|
2054
|
+
if (timeDiffHours > 0) {
|
|
2055
|
+
stays.push({
|
|
2056
|
+
destinationName: destination.name,
|
|
2057
|
+
durationHours: timeDiffHours,
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
} else {
|
|
2061
|
+
// For non-last destinations (including first), use durationHours only if there's more than one stop
|
|
2062
|
+
if (itinerary.length > 1 && itineraryItem.durationHours && itineraryItem.durationHours > 0) {
|
|
2063
|
+
stays.push({
|
|
2064
|
+
destinationName: destination.name,
|
|
2065
|
+
durationHours: itineraryItem.durationHours,
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const departureTime = itineraryItem.durationHours && !isLastDestination
|
|
2071
|
+
? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
|
|
2072
|
+
: arrivalTime;
|
|
2073
|
+
|
|
2074
|
+
lastDepartureTime = departureTime;
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
if (stays.length === 0) return null;
|
|
2078
|
+
|
|
2079
|
+
// Build summary string e.g. "2 hours at [destination] + 2 hours at [destination]"
|
|
2080
|
+
return stays.map(stay => {
|
|
2081
|
+
const hours = Math.floor(stay.durationHours);
|
|
2082
|
+
const minutes = Math.round((stay.durationHours - hours) * 60);
|
|
2083
|
+
let timeStr = '';
|
|
2084
|
+
if (hours === 0 && minutes === 0) {
|
|
2085
|
+
timeStr = '0 min';
|
|
2086
|
+
} else if (hours === 0) {
|
|
2087
|
+
timeStr = `${minutes} min`;
|
|
2088
|
+
} else if (minutes === 0) {
|
|
2089
|
+
timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
|
|
2090
|
+
} else {
|
|
2091
|
+
timeStr = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${minutes} min`;
|
|
2092
|
+
}
|
|
2093
|
+
return `${timeStr} at ${stay.destinationName}`;
|
|
2094
|
+
}).join(' + ');
|
|
2095
|
+
}, [selectedAvailability, activeOptions, product.destinations, getEffectiveItinerary]);
|
|
2096
|
+
|
|
2097
|
+
// Helper function to compute itinerary display for storage (returns same shape as "Your Itinerary" box)
|
|
2098
|
+
const computeItineraryDisplay = useCallback((): ItineraryDisplayStep[] | null => {
|
|
2099
|
+
if (!selectedAvailability) return null;
|
|
2100
|
+
const availabilityProductOptionId = selectedAvailability.productOptionId || activeOptions[0]?.optionId;
|
|
2101
|
+
const selectedOption = activeOptions.find(opt => opt.optionId === availabilityProductOptionId);
|
|
2102
|
+
if (!selectedOption) return null;
|
|
2103
|
+
const tourStartTime = parseISO(selectedAvailability.dateTime);
|
|
2104
|
+
const itinerary = getEffectiveItinerary(selectedOption, tourStartTime);
|
|
2105
|
+
const hasItinerary = itinerary && itinerary.length > 0 && product.destinations && product.destinations.length > 0;
|
|
2106
|
+
if (!hasItinerary || !product.destinations || !itinerary) return null;
|
|
2107
|
+
|
|
2108
|
+
const itineraryItems: ItineraryDisplayStep[] = [];
|
|
2109
|
+
const destinationMap = new Map(product.destinations.map(d => [d.name, d]));
|
|
2110
|
+
const pickupOffsetMinutes = pickupLocationSkipped ? 0 : (selectedPickupLocation?.pickupTimeOffsetMinutes ?? 0);
|
|
2111
|
+
const actualPickupTime = pickupOffsetMinutes > 0
|
|
2112
|
+
? new Date(tourStartTime.getTime() + pickupOffsetMinutes * 60 * 1000)
|
|
2113
|
+
: tourStartTime;
|
|
2114
|
+
let pickupTimeDisplay: string;
|
|
2115
|
+
if (pickupLocationSkipped && maxTimeOffsetMinutes > 0) {
|
|
2116
|
+
const latestPickupTime = new Date(tourStartTime.getTime() + maxTimeOffsetMinutes * 60 * 1000);
|
|
2117
|
+
pickupTimeDisplay = `${formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a')}`;
|
|
2118
|
+
} else {
|
|
2119
|
+
pickupTimeDisplay = formatInTimeZone(actualPickupTime, companyTimezone, 'h:mm a');
|
|
2120
|
+
}
|
|
2121
|
+
const pickupPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation.name;
|
|
2122
|
+
itineraryItems.push({ stepType: StepType.pickup, time: pickupTimeDisplay, place: pickupPlace });
|
|
2123
|
+
let lastDepartureTime = tourStartTime;
|
|
2124
|
+
itinerary.forEach((itineraryItem, index) => {
|
|
2125
|
+
const destination = destinationMap.get(itineraryItem.destinationName);
|
|
2126
|
+
if (!destination) return;
|
|
2127
|
+
const arrivalTime = new Date(lastDepartureTime.getTime() + (itineraryItem.travelTimeFromPreviousHours * 60 * 60 * 1000));
|
|
2128
|
+
itineraryItems.push({
|
|
2129
|
+
stepType: StepType.arrive,
|
|
2130
|
+
time: formatInTimeZone(arrivalTime, companyTimezone, 'h:mm a'),
|
|
2131
|
+
place: destination.name
|
|
2132
|
+
});
|
|
2133
|
+
const departureTime = itineraryItem.durationHours
|
|
2134
|
+
? new Date(arrivalTime.getTime() + (itineraryItem.durationHours * 60 * 60 * 1000))
|
|
2135
|
+
: arrivalTime;
|
|
2136
|
+
const hasMoreDestinations = index < itinerary.length - 1;
|
|
2137
|
+
if (itineraryItem.durationHours && hasMoreDestinations) {
|
|
2138
|
+
itineraryItems.push({
|
|
2139
|
+
stepType: StepType.depart,
|
|
2140
|
+
time: formatInTimeZone(departureTime, companyTimezone, 'h:mm a'),
|
|
2141
|
+
place: destination.name
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
lastDepartureTime = departureTime;
|
|
2145
|
+
});
|
|
2146
|
+
// Return time: from selected return option (products with return selection) OR from itinerary's last departure (e.g. Emerald Lake shuttle with fixed multi-stop itinerary)
|
|
2147
|
+
const returnDateTime = selectedReturnOption
|
|
2148
|
+
? parseISO(selectedReturnOption.dateTime)
|
|
2149
|
+
: lastDepartureTime;
|
|
2150
|
+
const lastDestination = itinerary.length > 0 ? destinationMap.get(itinerary[itinerary.length - 1].destinationName) : null;
|
|
2151
|
+
const lastDestName = itinerary.length > 0 && product.destinations
|
|
2152
|
+
? (destinationMap.get(itinerary[itinerary.length - 1].destinationName)?.name ?? null)
|
|
2153
|
+
: null;
|
|
2154
|
+
const getDropOffMinutes = (loc: { travelMinutesFromDestination?: Record<string, number> }) => {
|
|
2155
|
+
if (lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null) return loc.travelMinutesFromDestination[lastDestName];
|
|
2156
|
+
return 0;
|
|
2157
|
+
};
|
|
2158
|
+
const hasDropOffEstimate = (loc: { travelMinutesFromDestination?: Record<string, number> }) =>
|
|
2159
|
+
Boolean(lastDestName && loc.travelMinutesFromDestination?.[lastDestName] != null);
|
|
2160
|
+
const dropOffPlace = pickupLocationSkipped || !selectedPickupLocation ? 'your_pickup_location' : selectedPickupLocation!.name;
|
|
2161
|
+
|
|
2162
|
+
if (selectedReturnOption) {
|
|
2163
|
+
itineraryItems.push({
|
|
2164
|
+
stepType: StepType.depart,
|
|
2165
|
+
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
2166
|
+
place: lastDestination?.name ?? 'the_destination'
|
|
2167
|
+
});
|
|
2168
|
+
} else if (lastDestination) {
|
|
2169
|
+
// No return options: show depart from last stop (e.g. Vermillion Lakes for Emerald Lake shuttle)
|
|
2170
|
+
itineraryItems.push({
|
|
2171
|
+
stepType: StepType.depart,
|
|
2172
|
+
time: formatInTimeZone(returnDateTime, companyTimezone, 'h:mm a'),
|
|
2173
|
+
place: lastDestination.name
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
// Add drop-off step (works for both return-option products and itinerary-only products like Emerald Lake)
|
|
2177
|
+
if (selectedPickupLocation && hasDropOffEstimate(selectedPickupLocation)) {
|
|
2178
|
+
const dropOffOffsetMinutes = getDropOffMinutes(selectedPickupLocation);
|
|
2179
|
+
const dropOffTime = new Date(returnDateTime.getTime() + dropOffOffsetMinutes * 60 * 1000);
|
|
2180
|
+
itineraryItems.push({
|
|
2181
|
+
stepType: StepType.drop_off,
|
|
2182
|
+
time: formatInTimeZone(dropOffTime, companyTimezone, 'h:mm a'),
|
|
2183
|
+
place: dropOffPlace
|
|
2184
|
+
});
|
|
2185
|
+
} else if (pickupLocationSkipped || !selectedPickupLocation) {
|
|
2186
|
+
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
2187
|
+
} else if (product.pickupLocations?.length) {
|
|
2188
|
+
const dropOffOffsets = product.pickupLocations.map(getDropOffMinutes);
|
|
2189
|
+
const [minOffset, maxOffset] = [Math.min(...dropOffOffsets), Math.max(...dropOffOffsets)];
|
|
2190
|
+
const earliestDropOff = new Date(returnDateTime.getTime() + minOffset * 60 * 1000);
|
|
2191
|
+
const latestDropOff = new Date(returnDateTime.getTime() + maxOffset * 60 * 1000);
|
|
2192
|
+
itineraryItems.push({
|
|
2193
|
+
stepType: StepType.drop_off,
|
|
2194
|
+
time: minOffset === maxOffset
|
|
2195
|
+
? formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')
|
|
2196
|
+
: `${formatInTimeZone(earliestDropOff, companyTimezone, 'h:mm a')} - ${formatInTimeZone(latestDropOff, companyTimezone, 'h:mm a')}`,
|
|
2197
|
+
place: dropOffPlace
|
|
2198
|
+
});
|
|
2199
|
+
} else {
|
|
2200
|
+
itineraryItems.push({ stepType: StepType.drop_off, time: 'TBD', place: dropOffPlace });
|
|
2201
|
+
}
|
|
2202
|
+
return itineraryItems;
|
|
2203
|
+
}, [selectedAvailability, selectedReturnOption, selectedPickupLocation, pickupLocationSkipped, activeOptions, product, getEffectiveItinerary, companyTimezone, maxTimeOffsetMinutes]);
|
|
2204
|
+
|
|
2205
|
+
/**
|
|
2206
|
+
* Itinerary for storage only (API/DB). When pickup is unknown, pickup step time is always a range
|
|
2207
|
+
* (e.g. "9 AM - 10:00 AM") so /manage and confirmation email show it; drop-off stays TBD.
|
|
2208
|
+
* UI during booking is unchanged (uses computeItineraryDisplay).
|
|
2209
|
+
*/
|
|
2210
|
+
const computeItineraryDisplayForStorage = useCallback((): ItineraryDisplayStep[] | null => {
|
|
2211
|
+
const base = computeItineraryDisplay();
|
|
2212
|
+
if (!base || base.length === 0) return base;
|
|
2213
|
+
const pickupUnknown = pickupLocationSkipped || (!selectedPickupLocation && product.pickupLocations && product.pickupLocations.length > 0);
|
|
2214
|
+
if (!pickupUnknown) return base;
|
|
2215
|
+
const tourStartTime = selectedAvailability ? parseISO(selectedAvailability.dateTime) : null;
|
|
2216
|
+
if (!tourStartTime) return base;
|
|
2217
|
+
const rangeMinutes = maxTimeOffsetMinutes > 0 ? maxTimeOffsetMinutes : 60;
|
|
2218
|
+
const latestPickupTime = new Date(tourStartTime.getTime() + rangeMinutes * 60 * 1000);
|
|
2219
|
+
const startStr = formatInTimeZone(tourStartTime, companyTimezone, 'h:mm a');
|
|
2220
|
+
const endStr = formatInTimeZone(latestPickupTime, companyTimezone, 'h:mm a');
|
|
2221
|
+
const pickupRangeTime = startStr === endStr ? startStr : `${startStr} - ${endStr}`;
|
|
2222
|
+
return base.map((step, i) =>
|
|
2223
|
+
i === 0 && step.stepType === StepType.pickup && step.place === 'your_pickup_location'
|
|
2224
|
+
? { ...step, time: pickupRangeTime }
|
|
2225
|
+
: step
|
|
2226
|
+
);
|
|
2227
|
+
}, [computeItineraryDisplay, pickupLocationSkipped, selectedPickupLocation, product.pickupLocations, selectedAvailability, companyTimezone, maxTimeOffsetMinutes]);
|
|
2228
|
+
|
|
2229
|
+
// Product has fees from config (e.g. product-fees.json); API sends these in pricingConfig.fees
|
|
2230
|
+
const hasFees = useMemo(() =>
|
|
2231
|
+
Boolean(pricingConfig?.fees && Object.keys(pricingConfig.fees).length > 0 && Object.values(pricingConfig.fees).some(v => (v?.feePerPerson ?? 0) > 0)),
|
|
2232
|
+
[pricingConfig?.fees]
|
|
2233
|
+
);
|
|
2234
|
+
|
|
2235
|
+
const changeFlowTicketBookedUnitPriceByCategory = useMemo(() => {
|
|
2236
|
+
if (!originalReceipt?.lineItems?.length) return new Map<string, number>();
|
|
2237
|
+
const amountByCategory = new Map<string, number>();
|
|
2238
|
+
const qtyByCategory = new Map<string, number>();
|
|
2239
|
+
for (const line of originalReceipt.lineItems) {
|
|
2240
|
+
const type = (line.type || '').trim().toUpperCase();
|
|
2241
|
+
if (type !== 'TICKET') continue;
|
|
2242
|
+
const category = normalizeTicketCategoryFromReceiptLabel(line.label || '');
|
|
2243
|
+
const amount = Number(line.amount ?? 0);
|
|
2244
|
+
const qty = Number(line.quantity ?? 0);
|
|
2245
|
+
if (!category || !Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
|
|
2246
|
+
amountByCategory.set(category, (amountByCategory.get(category) ?? 0) + amount);
|
|
2247
|
+
qtyByCategory.set(category, (qtyByCategory.get(category) ?? 0) + qty);
|
|
2248
|
+
}
|
|
2249
|
+
const floors = new Map<string, number>();
|
|
2250
|
+
for (const [category, qty] of qtyByCategory.entries()) {
|
|
2251
|
+
const totalAmount = amountByCategory.get(category) ?? 0;
|
|
2252
|
+
if (totalAmount > 0 && qty > 0) floors.set(category, totalAmount / qty);
|
|
2253
|
+
}
|
|
2254
|
+
return floors;
|
|
2255
|
+
}, [originalReceipt?.lineItems]);
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
|
|
2259
|
+
* so catalog "free" return slots still show and price at the original return value in change flow.
|
|
2260
|
+
*/
|
|
2261
|
+
const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
|
|
2262
|
+
if (!originalReceipt?.lineItems?.length) return null;
|
|
2263
|
+
const fallbackBookedQty =
|
|
2264
|
+
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
2265
|
+
let totalAmount = 0;
|
|
2266
|
+
let totalQty = 0;
|
|
2267
|
+
for (const line of originalReceipt.lineItems) {
|
|
2268
|
+
const type = (line.type || '').trim().toUpperCase();
|
|
2269
|
+
if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
|
|
2270
|
+
const amount = Number(line.amount ?? 0);
|
|
2271
|
+
const qtyRaw = Number(line.quantity ?? 0);
|
|
2272
|
+
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
2273
|
+
if (!Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
|
|
2274
|
+
totalAmount += amount;
|
|
2275
|
+
totalQty += qty;
|
|
2276
|
+
}
|
|
2277
|
+
if (totalAmount <= 0 || totalQty <= 0) return null;
|
|
2278
|
+
return totalAmount / totalQty;
|
|
2279
|
+
}, [originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* Per-person minimum for return add-on display & totals whenever the booking paid for return on the receipt.
|
|
2283
|
+
* **Does not depend on** changing date vs staying on the original day, or switching product option — only on receipt/API.
|
|
2284
|
+
*/
|
|
2285
|
+
const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
|
|
2286
|
+
const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
|
|
2287
|
+
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
|
|
2288
|
+
const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
|
|
2289
|
+
if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
|
|
2290
|
+
return null;
|
|
2291
|
+
}, [initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
|
|
2292
|
+
const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
|
|
2293
|
+
const feeUnitByLabel = new Map<string, number>();
|
|
2294
|
+
if (!originalReceipt?.lineItems?.length) return feeUnitByLabel;
|
|
2295
|
+
const fallbackBookedQty =
|
|
2296
|
+
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
2297
|
+
for (const line of originalReceipt.lineItems) {
|
|
2298
|
+
const type = (line.type || '').trim().toUpperCase();
|
|
2299
|
+
if (!line.label || type === 'TICKET' || type === 'RETURN_OPTION' || type === 'TAX') continue;
|
|
2300
|
+
if (type.includes('PROMO') || type.includes('VOUCHER') || type.includes('GIFT')) continue;
|
|
2301
|
+
const amount = Number(line.amount ?? 0);
|
|
2302
|
+
const qtyRaw = Number(line.quantity ?? 0);
|
|
2303
|
+
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
2304
|
+
if (!Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
|
|
2305
|
+
const key = normalizeLineLabelForCompare(line.label);
|
|
2306
|
+
if (!key) continue;
|
|
2307
|
+
feeUnitByLabel.set(key, amount / qty);
|
|
2308
|
+
}
|
|
2309
|
+
return feeUnitByLabel;
|
|
2310
|
+
}, [originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2311
|
+
|
|
2312
|
+
const changeFlowInitialTicketQtyByCategory = useMemo(() => {
|
|
2313
|
+
const qtyByCategory = new Map<string, number>();
|
|
2314
|
+
if (!initialValues?.bookingItems?.length) return qtyByCategory;
|
|
2315
|
+
for (const item of initialValues.bookingItems) {
|
|
2316
|
+
const category = item.category?.trim().toUpperCase();
|
|
2317
|
+
if (!category) continue;
|
|
2318
|
+
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2319
|
+
}
|
|
2320
|
+
return qtyByCategory;
|
|
2321
|
+
}, [initialValues?.bookingItems]);
|
|
2322
|
+
const changeFlowInitialTicketCount = useMemo(() => {
|
|
2323
|
+
let sum = 0;
|
|
2324
|
+
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2325
|
+
return sum;
|
|
2326
|
+
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2327
|
+
const changeFlowOutboundMatchesOriginalSelection = useMemo(() => {
|
|
2328
|
+
if (!selectedAvailability || !initialValues?.dateTime) return false;
|
|
2329
|
+
const initialAvailabilityId = initialValues.availabilityId?.trim();
|
|
2330
|
+
const selectedAvailabilityId = selectedAvailability.availabilityId?.trim();
|
|
2331
|
+
const idsMatch =
|
|
2332
|
+
Boolean(initialAvailabilityId && selectedAvailabilityId) &&
|
|
2333
|
+
initialAvailabilityId === selectedAvailabilityId;
|
|
2334
|
+
if (idsMatch) return true;
|
|
2335
|
+
// Same wall time + same product option (ids may rotate on refresh).
|
|
2336
|
+
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2337
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2338
|
+
if (selectedMs !== initialMs) return false;
|
|
2339
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
2340
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
2341
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
2342
|
+
return selectedOpt === initialOpt;
|
|
2343
|
+
}
|
|
2344
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
2345
|
+
const legacySelected = selectedAvailability.productOptionId?.trim() || null;
|
|
2346
|
+
return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
|
|
2347
|
+
}, [
|
|
2348
|
+
selectedAvailability,
|
|
2349
|
+
initialValues?.availabilityId,
|
|
2350
|
+
initialValues?.dateTime,
|
|
2351
|
+
initialValues?.productOptionId,
|
|
2352
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2353
|
+
]);
|
|
2354
|
+
|
|
2355
|
+
/**
|
|
2356
|
+
* Rule A (exact locked ticket/fee units): same **calendar** departure day and same **product option** as the booking.
|
|
2357
|
+
* A different departure time on that day with the same PO still counts as unchanged (not tied to matching availability id).
|
|
2358
|
+
*/
|
|
2359
|
+
const changeFlowTicketPricingUnchangedItinerary = useMemo(() => {
|
|
2360
|
+
if (!selectedAvailability || !initialValues?.dateTime?.trim()) return false;
|
|
2361
|
+
let initialDay: string;
|
|
2362
|
+
try {
|
|
2363
|
+
initialDay = formatInTimeZone(
|
|
2364
|
+
parseAvailabilityDateTime(initialValues.dateTime.trim()),
|
|
2365
|
+
companyTimezone,
|
|
2366
|
+
'yyyy-MM-dd',
|
|
2367
|
+
);
|
|
2368
|
+
} catch {
|
|
2369
|
+
return false;
|
|
2370
|
+
}
|
|
2371
|
+
if (!selectedDate || selectedDate !== initialDay) return false;
|
|
2372
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
2373
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
2374
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
2375
|
+
return selectedOpt === initialOpt;
|
|
2376
|
+
}
|
|
2377
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
2378
|
+
const legacySelected = selectedAvailability.productOptionId?.trim() || null;
|
|
2379
|
+
return Boolean(legacySelected && legacyInitial && legacySelected === legacyInitial);
|
|
2380
|
+
}, [
|
|
2381
|
+
selectedDate,
|
|
2382
|
+
selectedAvailability,
|
|
2383
|
+
initialValues?.dateTime,
|
|
2384
|
+
initialValues?.productOptionId,
|
|
2385
|
+
companyTimezone,
|
|
2386
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2387
|
+
]);
|
|
2388
|
+
|
|
2389
|
+
const changeFlowProtectedReceiptPricing = useMemo((): ChangeFlowProtectedReceiptPricing => {
|
|
2390
|
+
return changeFlowTicketPricingUnchangedItinerary ? 'exact_from_receipt' : 'max_with_catalog';
|
|
2391
|
+
}, [changeFlowTicketPricingUnchangedItinerary]);
|
|
2392
|
+
|
|
2393
|
+
const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
|
|
2394
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
2395
|
+
const selectedReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
|
|
2396
|
+
const initialReturnDt = initialValues?.returnDateTime?.trim() || null;
|
|
2397
|
+
const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
|
|
2398
|
+
|
|
2399
|
+
if (!selectedReturnAvailabilityId) {
|
|
2400
|
+
return initialReturnAvailabilityId == null;
|
|
2401
|
+
}
|
|
2402
|
+
if (initialReturnAvailabilityId && selectedReturnAvailabilityId) {
|
|
2403
|
+
if (initialReturnAvailabilityId === selectedReturnAvailabilityId) return true;
|
|
2404
|
+
if (initialReturnDt && selectedReturnDt) {
|
|
2405
|
+
return (
|
|
2406
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2407
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
// Bookings often store returnDateTime without returnAvailabilityId; still the same return if wall times match.
|
|
2413
|
+
if (!initialReturnAvailabilityId && initialReturnDt && selectedReturnDt) {
|
|
2414
|
+
return (
|
|
2415
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2416
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2417
|
+
);
|
|
2418
|
+
}
|
|
2419
|
+
return false;
|
|
2420
|
+
}, [
|
|
2421
|
+
initialValues?.returnAvailabilityId,
|
|
2422
|
+
initialValues?.returnDateTime,
|
|
2423
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
2424
|
+
selectedReturnOption?.dateTime,
|
|
2425
|
+
]);
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* Mirrors BE `returnPricingRuleA` for the return row: receipt floor exists, unchanged outbound itinerary, and selected
|
|
2429
|
+
* return **`returnAvailabilityId` equals the booking’s** (strict id match; no wall-time fallback).
|
|
2430
|
+
*/
|
|
2431
|
+
const changeFlowReturnPricingRuleA = useMemo(() => {
|
|
2432
|
+
if (!changeFlowApplyReceiptPaidFloors) return false;
|
|
2433
|
+
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return false;
|
|
2434
|
+
if (!changeFlowTicketPricingUnchangedItinerary) return false;
|
|
2435
|
+
const booked = initialValues?.returnAvailabilityId?.trim() || null;
|
|
2436
|
+
const selected = selectedReturnOption?.returnAvailabilityId?.trim() || null;
|
|
2437
|
+
return Boolean(booked && selected && booked === selected);
|
|
2438
|
+
}, [
|
|
2439
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2440
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2441
|
+
changeFlowTicketPricingUnchangedItinerary,
|
|
2442
|
+
initialValues?.returnAvailabilityId,
|
|
2443
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
2444
|
+
]);
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* Self-serve: every return slot shows ≥ per-person paid on the receipt (quote overrides cannot undercut).
|
|
2448
|
+
* Same floor whether the guest changes calendar date, product option, or return time — not tied to “same slot as booking”.
|
|
2449
|
+
*/
|
|
2450
|
+
const returnOptionsWithFloor = useMemo(() => {
|
|
2451
|
+
const options = selectedAvailability?.returnOptions ?? [];
|
|
2452
|
+
const serverReturnMap = latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId;
|
|
2453
|
+
const floor = effectiveChangeFlowReturnUnitFloorPerPerson;
|
|
2454
|
+
const applyReturnFloor = floor != null && true;
|
|
2455
|
+
return options.map((opt) => {
|
|
2456
|
+
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2457
|
+
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2458
|
+
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2459
|
+
const flooredPerPerson = applyReturnFloor ? Math.max(rawPerPerson, floor) : rawPerPerson;
|
|
2460
|
+
let perPerson = flooredPerPerson;
|
|
2461
|
+
if (true && serverReturnMap && opt.returnAvailabilityId) {
|
|
2462
|
+
const sid = opt.returnAvailabilityId.trim();
|
|
2463
|
+
const sp = serverReturnMap[sid];
|
|
2464
|
+
if (sp != null && Number.isFinite(sp)) {
|
|
2465
|
+
perPerson = sp;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
if (applyReturnFloor && floor != null) {
|
|
2469
|
+
perPerson = Math.max(perPerson, floor);
|
|
2470
|
+
}
|
|
2471
|
+
if (perPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
|
|
2472
|
+
return {
|
|
2473
|
+
...opt,
|
|
2474
|
+
vacancies: adjustedVacancies,
|
|
2475
|
+
priceAdjustmentByCurrency: {
|
|
2476
|
+
...(opt.priceAdjustmentByCurrency ?? {}),
|
|
2477
|
+
[currency]: perPerson,
|
|
2478
|
+
},
|
|
2479
|
+
};
|
|
2480
|
+
});
|
|
2481
|
+
}, [
|
|
2482
|
+
selectedAvailability?.returnOptions,
|
|
2483
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2484
|
+
true,
|
|
2485
|
+
currency,
|
|
2486
|
+
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2487
|
+
latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId,
|
|
2488
|
+
]);
|
|
2489
|
+
|
|
2490
|
+
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2491
|
+
if (!selectedReturnOption) return selectedReturnOption;
|
|
2492
|
+
const floor = effectiveChangeFlowReturnUnitFloorPerPerson;
|
|
2493
|
+
const applyReturnFloor = floor != null && true;
|
|
2494
|
+
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2495
|
+
let perPerson = rawPerPerson;
|
|
2496
|
+
const serverReturnMap = latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId;
|
|
2497
|
+
if (true && serverReturnMap && selectedReturnOption.returnAvailabilityId) {
|
|
2498
|
+
const sid = selectedReturnOption.returnAvailabilityId.trim();
|
|
2499
|
+
const sp = serverReturnMap[sid];
|
|
2500
|
+
if (sp != null && Number.isFinite(sp)) {
|
|
2501
|
+
perPerson = sp;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (applyReturnFloor && floor != null) {
|
|
2505
|
+
perPerson = Math.max(perPerson, floor);
|
|
2506
|
+
}
|
|
2507
|
+
if (perPerson === rawPerPerson) return selectedReturnOption;
|
|
2508
|
+
return {
|
|
2509
|
+
...selectedReturnOption,
|
|
2510
|
+
priceAdjustmentByCurrency: {
|
|
2511
|
+
...(selectedReturnOption.priceAdjustmentByCurrency ?? {}),
|
|
2512
|
+
[currency]: perPerson,
|
|
2513
|
+
},
|
|
2514
|
+
};
|
|
2515
|
+
}, [
|
|
2516
|
+
selectedReturnOption,
|
|
2517
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2518
|
+
true,
|
|
2519
|
+
currency,
|
|
2520
|
+
latestChangeQuote?.serverPreview?.returnOptionPriceByReturnAvailabilityId,
|
|
2521
|
+
]);
|
|
2522
|
+
|
|
2523
|
+
/** Order-summary return row uses self-serve floors via [selectedReturnOptionWithFloor]; provider stays catalog-only. */
|
|
2524
|
+
const returnOptionForOrderSummary = useMemo(
|
|
2525
|
+
() => selectedReturnOptionWithFloor,
|
|
2526
|
+
[selectedReturnOptionWithFloor],
|
|
2527
|
+
);
|
|
2528
|
+
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2529
|
+
if (!selectedAvailability) return 0;
|
|
2530
|
+
const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
|
|
2531
|
+
return Math.max(0, selectedAvailability.vacancies ?? 0) + seatCredit;
|
|
2532
|
+
}, [selectedAvailability, changeFlowSeatCreditForOutboundAvailability]);
|
|
2533
|
+
const effectiveSelectedReturnVacancies = useMemo(() => {
|
|
2534
|
+
if (!selectedReturnOption) return null;
|
|
2535
|
+
const seatCredit = changeFlowSeatCreditForReturnAvailabilityId(
|
|
2536
|
+
selectedReturnOption.returnAvailabilityId
|
|
2537
|
+
);
|
|
2538
|
+
return Math.max(0, selectedReturnOption.vacancies ?? 0) + seatCredit;
|
|
2539
|
+
}, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
|
|
2540
|
+
|
|
2541
|
+
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2542
|
+
const pricing = useMemo(
|
|
2543
|
+
() =>
|
|
2544
|
+
buildPricingFromAvailability(
|
|
2545
|
+
selectedAvailability,
|
|
2546
|
+
activeOptions,
|
|
2547
|
+
precomputedPricesByOption,
|
|
2548
|
+
currency,
|
|
2549
|
+
pricingConfig,
|
|
2550
|
+
hasFees,
|
|
2551
|
+
isSimplifiedPricingView,
|
|
2552
|
+
),
|
|
2553
|
+
[selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
|
|
2554
|
+
);
|
|
2555
|
+
|
|
2556
|
+
/** Quote `ticketUnitPriceByCategory` overrides catalog units for ticket rows (customer self-serve only). */
|
|
2557
|
+
const pricingForTicketSelector = useMemo(() => {
|
|
2558
|
+
const m = latestChangeQuote?.serverPreview?.ticketUnitPriceByCategory;
|
|
2559
|
+
if (false || !m) return pricing;
|
|
2560
|
+
return pricing.map((r) => {
|
|
2561
|
+
const u = m[r.category.toUpperCase()];
|
|
2562
|
+
if (u == null || !Number.isFinite(u)) return r;
|
|
2563
|
+
return {
|
|
2564
|
+
...r,
|
|
2565
|
+
price: u,
|
|
2566
|
+
priceCAD: u,
|
|
2567
|
+
baseInDisplayCurrency: u,
|
|
2568
|
+
};
|
|
2569
|
+
});
|
|
2570
|
+
}, [pricing, latestChangeQuote?.serverPreview?.ticketUnitPriceByCategory, true]);
|
|
2571
|
+
|
|
2572
|
+
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2573
|
+
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
2574
|
+
if (!pricingConfig) return null;
|
|
2575
|
+
const selectedOption = activeOptions.find(opt => opt.optionId === selectedAvailability?.productOptionId);
|
|
2576
|
+
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
2577
|
+
const isPublicMode = isSimplifiedPricingView;
|
|
2578
|
+
return computePriceBreakdown(
|
|
2579
|
+
pricingConfig,
|
|
2580
|
+
currency,
|
|
2581
|
+
priceCAD,
|
|
2582
|
+
basePriceCAD,
|
|
2583
|
+
hasFees,
|
|
2584
|
+
appliedAdjustments,
|
|
2585
|
+
undefined,
|
|
2586
|
+
baseInDisplayCurrency,
|
|
2587
|
+
isPublicMode
|
|
2588
|
+
);
|
|
2589
|
+
}, [pricingConfig, currency, hasFees, activeOptions, selectedAvailability, isSimplifiedPricingView]);
|
|
2590
|
+
|
|
2591
|
+
// Order summary from mid-layer; UI only displays these values (no calculations).
|
|
2592
|
+
// Self-serve change: use quote ticket units (pricingForTicketSelector) so Rule B `liveUnit` matches BE `calculatePrice`,
|
|
2593
|
+
// not only the availability tile — avoids drift vs ticketUnitPriceByCategory / list column.
|
|
2594
|
+
const orderSummary: OrderSummary = useMemo(
|
|
2595
|
+
() =>
|
|
2596
|
+
computeOrderSummary(
|
|
2597
|
+
quantities,
|
|
2598
|
+
pricingForTicketSelector,
|
|
2599
|
+
returnOptionForOrderSummary,
|
|
2600
|
+
pricingConfig ?? null,
|
|
2601
|
+
currency,
|
|
2602
|
+
hasFees,
|
|
2603
|
+
cancellationPolicyId
|
|
2604
|
+
),
|
|
2605
|
+
[
|
|
2606
|
+
quantities,
|
|
2607
|
+
pricingForTicketSelector,
|
|
2608
|
+
returnOptionForOrderSummary,
|
|
2609
|
+
pricingConfig,
|
|
2610
|
+
currency,
|
|
2611
|
+
hasFees,
|
|
2612
|
+
cancellationPolicyId,
|
|
2613
|
+
]
|
|
2614
|
+
);
|
|
2615
|
+
|
|
2616
|
+
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2617
|
+
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2618
|
+
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2619
|
+
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2620
|
+
0,
|
|
2621
|
+
);
|
|
2622
|
+
if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
|
|
2623
|
+
return ticketLineItems.reduce((sum, line) => {
|
|
2624
|
+
const category = line.category?.trim().toUpperCase();
|
|
2625
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2626
|
+
if (!category || qty <= 0) return sum;
|
|
2627
|
+
return (
|
|
2628
|
+
sum +
|
|
2629
|
+
changeFlowTicketLineTotalWithReceiptFloor({
|
|
2630
|
+
qty,
|
|
2631
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2632
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2633
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2634
|
+
protectedReceiptPricing: changeFlowProtectedReceiptPricing,
|
|
2635
|
+
})
|
|
2636
|
+
);
|
|
2637
|
+
}, 0);
|
|
2638
|
+
}, [
|
|
2639
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2640
|
+
ticketLineItems,
|
|
2641
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2642
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2643
|
+
changeFlowProtectedReceiptPricing,
|
|
2644
|
+
]);
|
|
2645
|
+
/** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
|
|
2646
|
+
const effectivePartySizeCap = useMemo(() => {
|
|
2647
|
+
if (!selectedAvailability) return 0;
|
|
2648
|
+
const outboundSeatCredit =
|
|
2649
|
+
changeFlowOutboundMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2650
|
+
? changeFlowInitialTicketCount
|
|
2651
|
+
: 0;
|
|
2652
|
+
const outbound = Math.max(0, selectedAvailability.vacancies ?? 0) + outboundSeatCredit;
|
|
2653
|
+
if (selectedReturnOption == null) return outbound;
|
|
2654
|
+
const returnSeatCredit =
|
|
2655
|
+
changeFlowReturnMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2656
|
+
? changeFlowInitialTicketCount
|
|
2657
|
+
: 0;
|
|
2658
|
+
const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0) + returnSeatCredit;
|
|
2659
|
+
return Math.min(outbound, returnCap);
|
|
2660
|
+
}, [
|
|
2661
|
+
selectedAvailability,
|
|
2662
|
+
selectedReturnOption,
|
|
2663
|
+
changeFlowInitialTicketCount,
|
|
2664
|
+
changeFlowOutboundMatchesOriginalSelection,
|
|
2665
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2666
|
+
]);
|
|
2667
|
+
|
|
2668
|
+
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
2669
|
+
/** Label for display when policy may be forced by promo (not in pricingConfig list). */
|
|
2670
|
+
const effectiveCancellationPolicyLabel =
|
|
2671
|
+
selectedCancellationPolicy?.label ?? (t('booking.flexibleCancellation') || 'Flexible cancellation');
|
|
2672
|
+
|
|
2673
|
+
// When return selection (or refreshed availabilities) caps the party below current ticket counts, trim tickets (non-admin).
|
|
2674
|
+
useEffect(() => {
|
|
2675
|
+
if (false || !selectedAvailability) return;
|
|
2676
|
+
const cap = effectivePartySizeCap;
|
|
2677
|
+
if (totalQuantity <= cap) return;
|
|
2678
|
+
const over = totalQuantity - cap;
|
|
2679
|
+
setQuantities((prev) => {
|
|
2680
|
+
const next = { ...prev };
|
|
2681
|
+
let remaining = over;
|
|
2682
|
+
const cats = Object.keys(next)
|
|
2683
|
+
.filter((c) => (next[c] ?? 0) > 0)
|
|
2684
|
+
.sort((a, b) => (next[b] ?? 0) - (next[a] ?? 0));
|
|
2685
|
+
for (const cat of cats) {
|
|
2686
|
+
if (remaining <= 0) break;
|
|
2687
|
+
const minQ =
|
|
2688
|
+
changeBookingMinimumQuantities != null
|
|
2689
|
+
? Math.max(0, changeBookingMinimumQuantities[cat] ?? 0)
|
|
2690
|
+
: 0;
|
|
2691
|
+
const q = next[cat] ?? 0;
|
|
2692
|
+
const reducible = Math.max(0, q - minQ);
|
|
2693
|
+
const dec = Math.min(reducible, remaining);
|
|
2694
|
+
next[cat] = q - dec;
|
|
2695
|
+
remaining -= dec;
|
|
2696
|
+
}
|
|
2697
|
+
return next;
|
|
2698
|
+
});
|
|
2699
|
+
}, [
|
|
2700
|
+
effectivePartySizeCap,
|
|
2701
|
+
totalQuantity,
|
|
2702
|
+
selectedAvailability,
|
|
2703
|
+
false,
|
|
2704
|
+
changeBookingMinimumQuantities,
|
|
2705
|
+
]);
|
|
2706
|
+
|
|
2707
|
+
// Add-on totals (lunch, animals, etc.)
|
|
2708
|
+
const addOnTotal = useMemo(() => {
|
|
2709
|
+
let sum = 0;
|
|
2710
|
+
for (const sel of addOnSelections) {
|
|
2711
|
+
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
2712
|
+
if (!addOn) continue;
|
|
2713
|
+
const basePrice = addOn.price ?? 0;
|
|
2714
|
+
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
2715
|
+
const variantAdjustment = hasVariant
|
|
2716
|
+
? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0)
|
|
2717
|
+
: 0;
|
|
2718
|
+
sum += (basePrice + variantAdjustment) * (sel.quantity ?? 1);
|
|
2719
|
+
}
|
|
2720
|
+
return sum;
|
|
2721
|
+
}, [addOnSelections, addOns]);
|
|
2722
|
+
|
|
2723
|
+
// Effective subtotal includes add-ons (for promo discount and total)
|
|
2724
|
+
const currentTicketSubtotal = useMemo(
|
|
2725
|
+
() => ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0),
|
|
2726
|
+
[ticketLineItems],
|
|
2727
|
+
);
|
|
2728
|
+
const changeFlowInitialTotalTicketQty = changeFlowInitialTicketCount;
|
|
2729
|
+
const currentFeeSubtotal = useMemo(
|
|
2730
|
+
() => feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0),
|
|
2731
|
+
[feeLineItems],
|
|
2732
|
+
);
|
|
2733
|
+
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2734
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2735
|
+
const initialParty = changeFlowInitialTicketCount;
|
|
2736
|
+
return feeLineItems.reduce((sum, line) => {
|
|
2737
|
+
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2738
|
+
const bookedFeePerPerson = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : undefined;
|
|
2739
|
+
return (
|
|
2740
|
+
sum +
|
|
2741
|
+
changeFlowFeeLineTotalWithReceiptFloor({
|
|
2742
|
+
totalQuantity,
|
|
2743
|
+
initialTicketCount: initialParty,
|
|
2744
|
+
bookedFeePerPerson,
|
|
2745
|
+
liveFeeLineTotal: Number(line.totalAmount) || 0,
|
|
2746
|
+
protectedReceiptPricing: changeFlowProtectedReceiptPricing,
|
|
2747
|
+
})
|
|
2748
|
+
);
|
|
2749
|
+
}, 0);
|
|
2750
|
+
}, [
|
|
2751
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2752
|
+
totalQuantity,
|
|
2753
|
+
currentFeeSubtotal,
|
|
2754
|
+
feeLineItems,
|
|
2755
|
+
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2756
|
+
changeFlowInitialTicketCount,
|
|
2757
|
+
changeFlowProtectedReceiptPricing,
|
|
2758
|
+
]);
|
|
2759
|
+
/** Catalog (unfloored) return price per person — same slot as [selectedReturnOption] on the raw availability list. */
|
|
2760
|
+
const returnOptionCatalogPerPerson = useMemo(() => {
|
|
2761
|
+
if (!selectedReturnOption?.returnAvailabilityId || !selectedAvailability?.returnOptions?.length) {
|
|
2762
|
+
return null;
|
|
2763
|
+
}
|
|
2764
|
+
const raw = selectedAvailability.returnOptions.find(
|
|
2765
|
+
(o) => o.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
2766
|
+
);
|
|
2767
|
+
const v = raw?.priceAdjustmentByCurrency?.[currency];
|
|
2768
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
2769
|
+
}, [selectedReturnOption?.returnAvailabilityId, selectedAvailability?.returnOptions, currency]);
|
|
2770
|
+
|
|
2771
|
+
/**
|
|
2772
|
+
* Customer self-serve: return row total matches BE protected vs incremental headcount when a receipt return floor exists
|
|
2773
|
+
* (`changeFlowReturnLineTotalWithReceiptFloor`). Catalog-only return uses `returnPriceAdjustment` from order summary.
|
|
2774
|
+
*/
|
|
2775
|
+
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2776
|
+
if (totalQuantity <= 0) return returnPriceAdjustment;
|
|
2777
|
+
if (false) return returnPriceAdjustment;
|
|
2778
|
+
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2779
|
+
const livePerPerson =
|
|
2780
|
+
returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
|
|
2781
|
+
return changeFlowReturnLineTotalWithReceiptFloor({
|
|
2782
|
+
totalPersons: totalQuantity,
|
|
2783
|
+
baselineParty: changeFlowInitialTicketCount,
|
|
2784
|
+
livePerPerson,
|
|
2785
|
+
receiptFloorPerPerson: effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2786
|
+
returnRuleA: changeFlowReturnPricingRuleA,
|
|
2787
|
+
});
|
|
2788
|
+
}, [
|
|
2789
|
+
totalQuantity,
|
|
2790
|
+
returnPriceAdjustment,
|
|
2791
|
+
false,
|
|
2792
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2793
|
+
selectedReturnOption,
|
|
2794
|
+
returnOptionCatalogPerPerson,
|
|
2795
|
+
currency,
|
|
2796
|
+
changeFlowInitialTicketCount,
|
|
2797
|
+
changeFlowReturnPricingRuleA,
|
|
2798
|
+
]);
|
|
2799
|
+
|
|
2800
|
+
/** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
|
|
2801
|
+
const checkoutReturnLineAmount = useMemo(() => {
|
|
2802
|
+
if (true) {
|
|
2803
|
+
return changeFlowProtectedReturnAdjustment;
|
|
2804
|
+
}
|
|
2805
|
+
if (changeFlowApplyReceiptPaidFloors) {
|
|
2806
|
+
return changeFlowProtectedReturnAdjustment;
|
|
2807
|
+
}
|
|
2808
|
+
return returnPriceAdjustment;
|
|
2809
|
+
}, [
|
|
2810
|
+
true,
|
|
2811
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2812
|
+
changeFlowProtectedReturnAdjustment,
|
|
2813
|
+
returnPriceAdjustment,
|
|
2814
|
+
]);
|
|
2815
|
+
|
|
2816
|
+
/** Ticket lines with receipt floors applied for breakdown/modal (matches protected ticket subtotal). */
|
|
2817
|
+
const ticketLineItemsForChangeFlowDisplay = useMemo(() => {
|
|
2818
|
+
if (!changeFlowApplyReceiptPaidFloors) return ticketLineItems;
|
|
2819
|
+
return ticketLineItems.map((line) => {
|
|
2820
|
+
const category = line.category?.trim().toUpperCase();
|
|
2821
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2822
|
+
if (!category || qty <= 0) return line;
|
|
2823
|
+
const newTotal = changeFlowTicketLineTotalWithReceiptFloor({
|
|
2824
|
+
qty,
|
|
2825
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2826
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2827
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2828
|
+
protectedReceiptPricing: changeFlowProtectedReceiptPricing,
|
|
2829
|
+
});
|
|
2830
|
+
return { ...line, itemTotal: newTotal };
|
|
2831
|
+
});
|
|
2832
|
+
}, [
|
|
2833
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2834
|
+
ticketLineItems,
|
|
2835
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2836
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2837
|
+
changeFlowProtectedReceiptPricing,
|
|
2838
|
+
]);
|
|
2839
|
+
|
|
2840
|
+
const effectiveSubtotalBeforeAddOns =
|
|
2841
|
+
changeFlowApplyReceiptPaidFloors
|
|
2842
|
+
? subtotal -
|
|
2843
|
+
currentTicketSubtotal -
|
|
2844
|
+
currentFeeSubtotal -
|
|
2845
|
+
returnPriceAdjustment +
|
|
2846
|
+
changeFlowProtectedTicketSubtotal +
|
|
2847
|
+
changeFlowProtectedFeeSubtotal +
|
|
2848
|
+
changeFlowProtectedReturnAdjustment
|
|
2849
|
+
: subtotal;
|
|
2850
|
+
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
2851
|
+
|
|
2852
|
+
/** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
|
|
2853
|
+
const quantitiesSignature = useMemo(
|
|
2854
|
+
() =>
|
|
2855
|
+
Object.entries(quantities)
|
|
2856
|
+
.filter(([, n]) => (n ?? 0) > 0)
|
|
2857
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2858
|
+
.map(([c, n]) => `${c}:${n}`)
|
|
2859
|
+
.join('|'),
|
|
2860
|
+
[quantities]
|
|
2861
|
+
);
|
|
2862
|
+
|
|
2863
|
+
// Fee line items including add-ons (for PriceSummary and CheckoutModal)
|
|
2864
|
+
const feeLineItemsWithAddOns = useMemo(() => {
|
|
2865
|
+
const addOnLines = addOnSelections
|
|
2866
|
+
.map((sel) => {
|
|
2867
|
+
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
2868
|
+
if (!addOn) return null;
|
|
2869
|
+
const base = addOn.price ?? 0;
|
|
2870
|
+
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
2871
|
+
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
2872
|
+
const qty = sel.quantity ?? 1;
|
|
2873
|
+
const amt = (base + adj) * qty;
|
|
2874
|
+
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
2875
|
+
const name = variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name;
|
|
2876
|
+
return { name, totalAmount: amt, description: addOn.description ?? undefined };
|
|
2877
|
+
})
|
|
2878
|
+
.filter((x): x is NonNullable<typeof x> => x != null);
|
|
2879
|
+
return [...feeLineItems, ...addOnLines];
|
|
2880
|
+
}, [feeLineItems, addOnSelections, addOns]);
|
|
2881
|
+
|
|
2882
|
+
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2883
|
+
if (!selectedAvailability) return [];
|
|
2884
|
+
const returnLineAmount = checkoutReturnLineAmount;
|
|
2885
|
+
/** Show row when a return is selected and either the priced amount is non-zero or a receipt return floor exists (catalog slot can be $0 while floored total is not). */
|
|
2886
|
+
const showReturnLine =
|
|
2887
|
+
Boolean(selectedReturnOption) &&
|
|
2888
|
+
(Math.abs(returnLineAmount) > 0.0005 || effectiveChangeFlowReturnUnitFloorPerPerson != null);
|
|
2889
|
+
return [
|
|
2890
|
+
...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
|
|
2891
|
+
const rate = pricing.find((r) => r.category === line.category);
|
|
2892
|
+
const breakdown = getPriceBreakdown(
|
|
2893
|
+
line.category,
|
|
2894
|
+
rate?.priceCAD ?? 0,
|
|
2895
|
+
rate?.baseInDisplayCurrency,
|
|
2896
|
+
rate?.appliedAdjustments ?? [],
|
|
2897
|
+
);
|
|
2898
|
+
return {
|
|
2899
|
+
kind: 'ticket',
|
|
2900
|
+
lineKey: undefined,
|
|
2901
|
+
editable: false,
|
|
2902
|
+
category: line.category,
|
|
2903
|
+
qty: line.qty,
|
|
2904
|
+
itemTotal: line.itemTotal,
|
|
2905
|
+
breakdown,
|
|
2906
|
+
};
|
|
2907
|
+
}),
|
|
2908
|
+
...(showReturnLine
|
|
2909
|
+
? [
|
|
2910
|
+
{
|
|
2911
|
+
kind: 'line' as const,
|
|
2912
|
+
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
2913
|
+
amount: returnLineAmount,
|
|
2914
|
+
type: 'return',
|
|
2915
|
+
},
|
|
2916
|
+
]
|
|
2917
|
+
: []),
|
|
2918
|
+
...(cancellationPolicyFee > 0 && selectedCancellationPolicy
|
|
2919
|
+
? [
|
|
2920
|
+
{
|
|
2921
|
+
kind: 'line' as const,
|
|
2922
|
+
label: effectiveCancellationPolicyLabel,
|
|
2923
|
+
amount: cancellationPolicyFee,
|
|
2924
|
+
type: 'cancellation',
|
|
2925
|
+
},
|
|
2926
|
+
]
|
|
2927
|
+
: []),
|
|
2928
|
+
...feeLineItemsWithAddOns.map((fee) => {
|
|
2929
|
+
const isMoraineLakeRoadAccessFee =
|
|
2930
|
+
fee.name.toLowerCase().includes('moraine') &&
|
|
2931
|
+
(fee.name.toLowerCase().includes('access') ||
|
|
2932
|
+
fee.name.toLowerCase().includes('road') ||
|
|
2933
|
+
fee.name.toLowerCase().includes('license'));
|
|
2934
|
+
return {
|
|
2935
|
+
kind: 'line' as const,
|
|
2936
|
+
lineKey: undefined,
|
|
2937
|
+
editable: false,
|
|
2938
|
+
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2939
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2940
|
+
: fee.name,
|
|
2941
|
+
amount: fee.totalAmount,
|
|
2942
|
+
type: 'fee',
|
|
2943
|
+
tooltip: isMoraineLakeRoadAccessFee
|
|
2944
|
+
? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
|
|
2945
|
+
: undefined,
|
|
2946
|
+
};
|
|
2947
|
+
}),
|
|
2948
|
+
];
|
|
2949
|
+
}, [
|
|
2950
|
+
selectedAvailability,
|
|
2951
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
2952
|
+
pricing,
|
|
2953
|
+
getPriceBreakdown,
|
|
2954
|
+
selectedReturnOption,
|
|
2955
|
+
checkoutReturnLineAmount,
|
|
2956
|
+
totalQuantity,
|
|
2957
|
+
t,
|
|
2958
|
+
cancellationPolicyFee,
|
|
2959
|
+
selectedCancellationPolicy,
|
|
2960
|
+
effectiveCancellationPolicyLabel,
|
|
2961
|
+
feeLineItemsWithAddOns,
|
|
2962
|
+
feeLineItems,
|
|
2963
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2964
|
+
]);
|
|
2965
|
+
|
|
2966
|
+
const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
|
|
2967
|
+
let raw: PriceSummaryLine[];
|
|
2968
|
+
if (suppressSelfServeCurrencyUi && selfServePricingConfirmed) {
|
|
2969
|
+
const serverLines = latestChangeQuote?.serverPreview?.priceSummaryLines;
|
|
2970
|
+
raw = serverLines && serverLines.length > 0 ? serverLines : checkoutPriceSummaryLines;
|
|
2971
|
+
} else {
|
|
2972
|
+
raw = checkoutPriceSummaryLines;
|
|
2973
|
+
}
|
|
2974
|
+
return omitZeroAmountPromoDiscountSummaryLines(raw);
|
|
2975
|
+
}, [
|
|
2976
|
+
suppressSelfServeCurrencyUi,
|
|
2977
|
+
selfServePricingConfirmed,
|
|
2978
|
+
checkoutPriceSummaryLines,
|
|
2979
|
+
latestChangeQuote?.serverPreview?.priceSummaryLines,
|
|
2980
|
+
]);
|
|
2981
|
+
|
|
2982
|
+
/** Receipt/server lines already include {@link PriceSummary}'s TAX row — do not also pass `taxAmount` or it duplicates. */
|
|
2983
|
+
const priceSummaryLinesIncludeTaxRow = useMemo(
|
|
2984
|
+
() =>
|
|
2985
|
+
checkoutPriceSummaryLinesForCheckout.some(
|
|
2986
|
+
(line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
|
|
2987
|
+
),
|
|
2988
|
+
[checkoutPriceSummaryLinesForCheckout],
|
|
2989
|
+
);
|
|
2990
|
+
|
|
2991
|
+
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
2992
|
+
const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
|
|
2993
|
+
const [isGiftCard, setIsGiftCard] = useState(false);
|
|
2994
|
+
const [isVoucher, setIsVoucher] = useState(false);
|
|
2995
|
+
/** Monotonic id per discount fetch; stale responses are ignored. */
|
|
2996
|
+
const promoDiscountFetchGenerationRef = useRef(0);
|
|
2997
|
+
/** Latest cart context for get-promo-discount; effect only keys off promoDiscountFetchKey. */
|
|
2998
|
+
const promoDiscountParamsRef = useRef({
|
|
2999
|
+
selectedAvailability: null as Availability | null,
|
|
3000
|
+
ticketLineItems: [] as Array<{ category: string; qty: number }>,
|
|
3001
|
+
effectiveSubtotal: 0,
|
|
3002
|
+
appliedPromoCode: null as string | null,
|
|
3003
|
+
});
|
|
3004
|
+
|
|
3005
|
+
const promoDiscountFetchKey = useMemo(() => {
|
|
3006
|
+
if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) return '';
|
|
3007
|
+
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
3008
|
+
if (!companyId) return '';
|
|
3009
|
+
const optionId = selectedAvailability.productOptionId;
|
|
3010
|
+
if (!optionId || !quantitiesSignature) return '';
|
|
3011
|
+
return [
|
|
3012
|
+
appliedPromoCode,
|
|
3013
|
+
companyId,
|
|
3014
|
+
product.productId,
|
|
3015
|
+
optionId,
|
|
3016
|
+
selectedAvailability.dateTime,
|
|
3017
|
+
selectedAvailability.availabilityId ?? '',
|
|
3018
|
+
currency,
|
|
3019
|
+
quantitiesSignature,
|
|
3020
|
+
String(Math.round(effectiveSubtotal * 100)),
|
|
3021
|
+
].join('::');
|
|
3022
|
+
}, [
|
|
3023
|
+
appliedPromoCode,
|
|
3024
|
+
selectedAvailability?.dateTime,
|
|
3025
|
+
selectedAvailability?.productOptionId,
|
|
3026
|
+
selectedAvailability?.availabilityId,
|
|
3027
|
+
totalQuantity,
|
|
3028
|
+
product.companyId,
|
|
3029
|
+
product.productId,
|
|
3030
|
+
currency,
|
|
3031
|
+
quantitiesSignature,
|
|
3032
|
+
effectiveSubtotal,
|
|
3033
|
+
]);
|
|
3034
|
+
|
|
3035
|
+
promoDiscountParamsRef.current = {
|
|
3036
|
+
selectedAvailability,
|
|
3037
|
+
ticketLineItems: ticketLineItems.map((l) => ({ category: l.category, qty: l.qty })),
|
|
3038
|
+
effectiveSubtotal,
|
|
3039
|
+
appliedPromoCode,
|
|
3040
|
+
};
|
|
3041
|
+
|
|
3042
|
+
useEffect(() => {
|
|
3043
|
+
if (!promoDiscountFetchKey) {
|
|
3044
|
+
setPromoDiscountAmount(0);
|
|
3045
|
+
setIsGiftCard(false);
|
|
3046
|
+
setIsVoucher(false);
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
let cancelled = false;
|
|
3051
|
+
const debounceMs = 120;
|
|
3052
|
+
const timer = window.setTimeout(() => {
|
|
3053
|
+
if (cancelled) return;
|
|
3054
|
+
const {
|
|
3055
|
+
selectedAvailability: sel,
|
|
3056
|
+
ticketLineItems: lines,
|
|
3057
|
+
effectiveSubtotal: sub,
|
|
3058
|
+
appliedPromoCode: code,
|
|
3059
|
+
} = promoDiscountParamsRef.current;
|
|
3060
|
+
if (!code || !sel) return;
|
|
3061
|
+
const companyId = product.companyId ?? env.COMPANY_ID;
|
|
3062
|
+
const optionId = sel.productOptionId;
|
|
3063
|
+
if (!companyId || !optionId) return;
|
|
3064
|
+
const items = lines.map((l) => ({ category: l.category, qty: l.qty }));
|
|
3065
|
+
if (items.length === 0) return;
|
|
3066
|
+
|
|
3067
|
+
const generation = ++promoDiscountFetchGenerationRef.current;
|
|
3068
|
+
getPromoDiscount(
|
|
3069
|
+
code,
|
|
3070
|
+
companyId,
|
|
3071
|
+
product.productId,
|
|
3072
|
+
optionId,
|
|
3073
|
+
currency,
|
|
3074
|
+
items,
|
|
3075
|
+
sel.dateTime,
|
|
3076
|
+
sub
|
|
3077
|
+
)
|
|
3078
|
+
.then((res) => {
|
|
3079
|
+
if (cancelled) return;
|
|
3080
|
+
if (generation !== promoDiscountFetchGenerationRef.current) return;
|
|
3081
|
+
setPromoDiscountAmount(res.discount ?? 0);
|
|
3082
|
+
setIsGiftCard(res.isGiftCard ?? false);
|
|
3083
|
+
setIsVoucher(res.isVoucher ?? false);
|
|
3084
|
+
})
|
|
3085
|
+
.catch(() => {
|
|
3086
|
+
if (cancelled) return;
|
|
3087
|
+
if (generation !== promoDiscountFetchGenerationRef.current) return;
|
|
3088
|
+
setPromoDiscountAmount(0);
|
|
3089
|
+
setIsGiftCard(false);
|
|
3090
|
+
setIsVoucher(false);
|
|
3091
|
+
});
|
|
3092
|
+
}, debounceMs);
|
|
3093
|
+
|
|
3094
|
+
return () => {
|
|
3095
|
+
cancelled = true;
|
|
3096
|
+
window.clearTimeout(timer);
|
|
3097
|
+
};
|
|
3098
|
+
}, [promoDiscountFetchKey, product.companyId, product.productId, currency]);
|
|
3099
|
+
|
|
3100
|
+
// Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
|
|
3101
|
+
// Vouchers and gift cards: tax on full subtotal (voucher discount includes tax on free portion; gift card is payment).
|
|
3102
|
+
// Change booking: same as normal — discount from get-promo-discount on the new selection (promo code from booking).
|
|
3103
|
+
const lockedPromoFallbackAmount =
|
|
3104
|
+
lockedPromoCode
|
|
3105
|
+
? Math.max(0, originalReceipt?.promoAmount ?? 0)
|
|
3106
|
+
: 0;
|
|
3107
|
+
const effectivePromoDiscountAmount =
|
|
3108
|
+
promoDiscountAmount > 0 ? promoDiscountAmount : lockedPromoFallbackAmount;
|
|
3109
|
+
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
|
|
3110
|
+
const effectiveTax =
|
|
3111
|
+
effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
3112
|
+
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3113
|
+
: taxOnSubtotal;
|
|
3114
|
+
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
3115
|
+
/**
|
|
3116
|
+
* FE cart rollup for line math, breakdowns, and `clientProposedTotal` hint to the API. Self-serve **footer** totals
|
|
3117
|
+
* prefer `latestChangeQuote` once quote succeeds — this value is not an alternate source of truth for checkout.
|
|
3118
|
+
*/
|
|
3119
|
+
const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
|
|
3120
|
+
cartTotal: totalPrice,
|
|
3121
|
+
originalReceiptTotal: originalReceipt?.total,
|
|
3122
|
+
});
|
|
3123
|
+
|
|
3124
|
+
/** When quote blocks checkout, compare FE vs server lines for debugging (prefers `pricingDriftDetail` from API when sent). */
|
|
3125
|
+
const quoteBlockedPricingDrift = useMemo(() => {
|
|
3126
|
+
if (!suppressSelfServeCurrencyUi || latestChangeQuote?.canProceed !== false) return null;
|
|
3127
|
+
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
3128
|
+
|
|
3129
|
+
const api = latestChangeQuote.pricingDriftDetail;
|
|
3130
|
+
const currencyForFmt = (latestChangeQuote.currency ?? currency) as Currency;
|
|
3131
|
+
|
|
3132
|
+
const clientMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.clientLineItems);
|
|
3133
|
+
const useBeClientLines = clientMappedFromApi.length > 0;
|
|
3134
|
+
const clientLinesForMerge = useBeClientLines ? clientMappedFromApi : checkoutPriceSummaryLines;
|
|
3135
|
+
|
|
3136
|
+
const serverMappedFromApi = mapQuoteLineItemsToPriceSummaryLines(api?.serverLineItems);
|
|
3137
|
+
const useBeServerLines = serverMappedFromApi.length > 0;
|
|
3138
|
+
const serverLinesForMerge = useBeServerLines
|
|
3139
|
+
? serverMappedFromApi
|
|
3140
|
+
: (latestChangeQuote.serverPreview?.priceSummaryLines ?? []);
|
|
3141
|
+
|
|
3142
|
+
const clientTotalForDrift =
|
|
3143
|
+
api?.clientTotalMajorUnits != null && Number.isFinite(api.clientTotalMajorUnits)
|
|
3144
|
+
? api.clientTotalMajorUnits
|
|
3145
|
+
: useBeClientLines
|
|
3146
|
+
? sumPriceSummaryLinesMajorUnits(clientMappedFromApi)
|
|
3147
|
+
: changeFlowNewBookingTotal;
|
|
3148
|
+
|
|
3149
|
+
let totalDelta: number | null = null;
|
|
3150
|
+
if (api?.deltaMajorUnits != null && Number.isFinite(api.deltaMajorUnits)) {
|
|
3151
|
+
totalDelta = roundMoney(api.deltaMajorUnits);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
/**
|
|
3155
|
+
* Server “price check” total: explicit API fields → sum of BE server lines → derive from delta → receipt preview totals.
|
|
3156
|
+
*/
|
|
3157
|
+
let serverTotalFromQuote: number | undefined =
|
|
3158
|
+
api?.serverTotalMajorUnits != null && Number.isFinite(api.serverTotalMajorUnits)
|
|
3159
|
+
? api.serverTotalMajorUnits
|
|
3160
|
+
: undefined;
|
|
3161
|
+
if (serverTotalFromQuote == null && useBeServerLines) {
|
|
3162
|
+
serverTotalFromQuote = sumPriceSummaryLinesMajorUnits(serverMappedFromApi);
|
|
3163
|
+
}
|
|
3164
|
+
if (serverTotalFromQuote == null && totalDelta != null) {
|
|
3165
|
+
serverTotalFromQuote = roundMoney(clientTotalForDrift - totalDelta);
|
|
3166
|
+
}
|
|
3167
|
+
if (serverTotalFromQuote == null) {
|
|
3168
|
+
serverTotalFromQuote =
|
|
3169
|
+
latestChangeQuote.serverDisplay?.total ??
|
|
3170
|
+
latestChangeQuote.serverPreview?.totalNewBooking ??
|
|
3171
|
+
undefined;
|
|
3172
|
+
}
|
|
3173
|
+
if (totalDelta == null && serverTotalFromQuote != null && Number.isFinite(serverTotalFromQuote)) {
|
|
3174
|
+
totalDelta = roundMoney(clientTotalForDrift - serverTotalFromQuote);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
const mergedForDrift = mergePriceSummaryLinesForDrift(clientLinesForMerge, serverLinesForMerge);
|
|
3178
|
+
let rows =
|
|
3179
|
+
api?.lineComparisons && api.lineComparisons.length > 0
|
|
3180
|
+
? enrichLineComparisonsWithMergedRows(api.lineComparisons, mergedForDrift)
|
|
3181
|
+
: mergedForDrift;
|
|
3182
|
+
|
|
3183
|
+
const hasTotalDelta = totalDelta != null && Math.abs(totalDelta) >= 0.005;
|
|
3184
|
+
|
|
3185
|
+
if (rows.length === 0 && !hasTotalDelta) {
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
const usesBeLinePayload = useBeClientLines || useBeServerLines;
|
|
3190
|
+
|
|
3191
|
+
const ticketCartDetail = ticketLineItemsForChangeFlowDisplay
|
|
3192
|
+
.filter((l) => (l.qty ?? 0) > 0)
|
|
3193
|
+
.map((line) => {
|
|
3194
|
+
const cat = line.category?.trim().toUpperCase() ?? '';
|
|
3195
|
+
const rate = pricingForTicketSelector.find((r) => r.category.toUpperCase() === cat);
|
|
3196
|
+
const rawList = rate?.baseInDisplayCurrency ?? rate?.priceCAD;
|
|
3197
|
+
const listUnit =
|
|
3198
|
+
rawList != null && Number.isFinite(Number(rawList))
|
|
3199
|
+
? Number(rawList)
|
|
3200
|
+
: line.qty > 0
|
|
3201
|
+
? line.itemTotal / line.qty
|
|
3202
|
+
: 0;
|
|
3203
|
+
return {
|
|
3204
|
+
category: cat,
|
|
3205
|
+
qty: line.qty,
|
|
3206
|
+
listUnitMajor: roundMoney(listUnit),
|
|
3207
|
+
effectiveUnitMajor: roundMoney(line.qty > 0 ? line.itemTotal / line.qty : 0),
|
|
3208
|
+
lineTotalMajor: line.itemTotal,
|
|
3209
|
+
};
|
|
3210
|
+
});
|
|
3211
|
+
|
|
3212
|
+
return (
|
|
3213
|
+
<ChangeBookingPricingDriftPanel
|
|
3214
|
+
rows={rows}
|
|
3215
|
+
clientTotal={clientTotalForDrift}
|
|
3216
|
+
serverTotal={serverTotalFromQuote}
|
|
3217
|
+
totalDelta={totalDelta}
|
|
3218
|
+
currency={currencyForFmt}
|
|
3219
|
+
locale={locale}
|
|
3220
|
+
ticketCartDetail={ticketCartDetail.length > 0 ? ticketCartDetail : undefined}
|
|
3221
|
+
serverTicketPricingTrace={latestChangeQuote.ticketPricingTrace ?? undefined}
|
|
3222
|
+
footnote={
|
|
3223
|
+
usesBeLinePayload
|
|
3224
|
+
? 'Lines use pricingDriftDetail.clientLineItems / serverLineItems when the quote includes them; totals prefer explicit major-unit fields, then sums of those lines, then the live cart / receipt preview.'
|
|
3225
|
+
: undefined
|
|
3226
|
+
}
|
|
3227
|
+
/>
|
|
3228
|
+
);
|
|
3229
|
+
}, [
|
|
3230
|
+
suppressSelfServeCurrencyUi,
|
|
3231
|
+
latestChangeQuote,
|
|
3232
|
+
selectedAvailability,
|
|
3233
|
+
totalQuantity,
|
|
3234
|
+
checkoutPriceSummaryLines,
|
|
3235
|
+
changeFlowNewBookingTotal,
|
|
3236
|
+
currency,
|
|
3237
|
+
locale,
|
|
3238
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
3239
|
+
pricingForTicketSelector,
|
|
3240
|
+
]);
|
|
3241
|
+
|
|
3242
|
+
/** Replaces PriceSummary with non-numeric status until quote returns authoritative totals (no FE dollar amounts). */
|
|
3243
|
+
const selfServeCheckoutPlaceholder = useMemo(() => {
|
|
3244
|
+
if (!suppressSelfServeCurrencyUi || !selectedAvailability || totalQuantity <= 0) return undefined;
|
|
3245
|
+
if (selfServePricingConfirmed) return undefined;
|
|
3246
|
+
if (changeQuoteLoading) {
|
|
3247
|
+
return (
|
|
3248
|
+
<div className="rounded-lg border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
|
3249
|
+
{t('booking.updatingPrice') !== 'booking.updatingPrice' ? t('booking.updatingPrice') : 'Getting confirmed price…'}
|
|
3250
|
+
</div>
|
|
3251
|
+
);
|
|
3252
|
+
}
|
|
3253
|
+
if (changeQuoteFetchError) {
|
|
3254
|
+
return (
|
|
3255
|
+
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800" role="alert">
|
|
3256
|
+
{changeQuoteFetchError}
|
|
3257
|
+
</div>
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
if (latestChangeQuote?.canProceed === false) {
|
|
3261
|
+
return (
|
|
3262
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
|
3263
|
+
<div>{latestChangeQuote.reasonIfBlocked ?? 'This booking change is not available.'}</div>
|
|
3264
|
+
{quoteBlockedPricingDrift}
|
|
3265
|
+
</div>
|
|
3266
|
+
);
|
|
3267
|
+
}
|
|
3268
|
+
return (
|
|
3269
|
+
<div className="rounded-lg border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
|
3270
|
+
Prices update once your selection is confirmed with our booking system.
|
|
3271
|
+
</div>
|
|
3272
|
+
);
|
|
3273
|
+
}, [
|
|
3274
|
+
suppressSelfServeCurrencyUi,
|
|
3275
|
+
selectedAvailability,
|
|
3276
|
+
totalQuantity,
|
|
3277
|
+
selfServePricingConfirmed,
|
|
3278
|
+
changeQuoteLoading,
|
|
3279
|
+
changeQuoteFetchError,
|
|
3280
|
+
latestChangeQuote,
|
|
3281
|
+
quoteBlockedPricingDrift,
|
|
3282
|
+
t,
|
|
3283
|
+
]);
|
|
3284
|
+
|
|
3285
|
+
const changeSelectionDetails = useMemo(() => {
|
|
3286
|
+
if (!initialValues) {
|
|
3287
|
+
return {
|
|
3288
|
+
hasChangesFromInitial: false,
|
|
3289
|
+
hasOperationalChangesFromInitial: false,
|
|
3290
|
+
dateChanged: false,
|
|
3291
|
+
ticketsChanged: false,
|
|
3292
|
+
optionChanged: false,
|
|
3293
|
+
pickupChanged: false,
|
|
3294
|
+
countsChanged: false,
|
|
3295
|
+
addOnsChanged: false,
|
|
3296
|
+
returnChanged: false,
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
if (!selectedAvailability) {
|
|
3300
|
+
return {
|
|
3301
|
+
hasChangesFromInitial: false,
|
|
3302
|
+
hasOperationalChangesFromInitial: false,
|
|
3303
|
+
dateChanged: false,
|
|
3304
|
+
ticketsChanged: false,
|
|
3305
|
+
optionChanged: false,
|
|
3306
|
+
pickupChanged: false,
|
|
3307
|
+
countsChanged: false,
|
|
3308
|
+
addOnsChanged: false,
|
|
3309
|
+
returnChanged: false,
|
|
3310
|
+
};
|
|
3311
|
+
}
|
|
3312
|
+
const initialMs = initialValues.dateTime ? parseAvailabilityDateTime(initialValues.dateTime).getTime() : null;
|
|
3313
|
+
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
3314
|
+
// Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
|
|
3315
|
+
const dateChanged = initialMs != null && initialMs !== selectedMs;
|
|
3316
|
+
const initialOpt =
|
|
3317
|
+
changeFlowResolvedInitialProductOptionId ??
|
|
3318
|
+
(initialValues.productOptionId?.trim() || null);
|
|
3319
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
3320
|
+
const optionChanged = Boolean(
|
|
3321
|
+
selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
|
|
3322
|
+
);
|
|
3323
|
+
const normalizePickupId = (value: string | null | undefined) => {
|
|
3324
|
+
const trimmed = value?.trim();
|
|
3325
|
+
return trimmed ? trimmed : null;
|
|
3326
|
+
};
|
|
3327
|
+
const pickupChanged =
|
|
3328
|
+
normalizePickupId(initialValues.pickupLocationId ?? null) !==
|
|
3329
|
+
normalizePickupId(pickupLocationId ?? null);
|
|
3330
|
+
const normalizeCounts = (items: Array<{ category: string; count: number }> | null | undefined) => {
|
|
3331
|
+
const map = new Map<string, number>();
|
|
3332
|
+
for (const item of items ?? []) {
|
|
3333
|
+
const key = item.category?.trim();
|
|
3334
|
+
if (!key) continue;
|
|
3335
|
+
map.set(key, Math.max(0, Number(item.count) || 0));
|
|
3336
|
+
}
|
|
3337
|
+
return map;
|
|
3338
|
+
};
|
|
3339
|
+
const initialCounts = normalizeCounts(initialValues.bookingItems ?? null);
|
|
3340
|
+
const currentCounts = new Map<string, number>();
|
|
3341
|
+
for (const [category, count] of Object.entries(quantities)) {
|
|
3342
|
+
const key = category?.trim();
|
|
3343
|
+
if (!key) continue;
|
|
3344
|
+
currentCounts.set(key, Math.max(0, Number(count) || 0));
|
|
3345
|
+
}
|
|
3346
|
+
const allKeys = new Set([...initialCounts.keys(), ...currentCounts.keys()]);
|
|
3347
|
+
let countsChanged = false;
|
|
3348
|
+
for (const key of allKeys) {
|
|
3349
|
+
if ((initialCounts.get(key) ?? 0) !== (currentCounts.get(key) ?? 0)) {
|
|
3350
|
+
countsChanged = true;
|
|
3351
|
+
break;
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
const currentAddOnQtyByKey = new Map<string, number>();
|
|
3355
|
+
for (const sel of addOnSelections) {
|
|
3356
|
+
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
3357
|
+
currentAddOnQtyByKey.set(key, (currentAddOnQtyByKey.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
3358
|
+
}
|
|
3359
|
+
const allAddOnKeys = new Set([...initialAddOnMinQtyByKey.keys(), ...currentAddOnQtyByKey.keys()]);
|
|
3360
|
+
let addOnsChanged = false;
|
|
3361
|
+
for (const key of allAddOnKeys) {
|
|
3362
|
+
if ((initialAddOnMinQtyByKey.get(key) ?? 0) !== (currentAddOnQtyByKey.get(key) ?? 0)) {
|
|
3363
|
+
addOnsChanged = true;
|
|
3364
|
+
break;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
const hasReturnOptions = (selectedAvailability?.returnOptions?.length ?? 0) > 0;
|
|
3368
|
+
const initialReturnId = initialValues.returnAvailabilityId?.trim() || null;
|
|
3369
|
+
const initialReturnDt = initialValues.returnDateTime?.trim() || null;
|
|
3370
|
+
const selectedReturnId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
|
|
3371
|
+
const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
|
|
3372
|
+
let returnChanged = false;
|
|
3373
|
+
if (hasReturnOptions && selectedReturnOption) {
|
|
3374
|
+
if (initialReturnId && selectedReturnId) {
|
|
3375
|
+
if (initialReturnId === selectedReturnId) {
|
|
3376
|
+
returnChanged = false;
|
|
3377
|
+
} else if (initialReturnDt && selectedReturnDt) {
|
|
3378
|
+
// Some refreshes can rotate return availability IDs for the same departure time.
|
|
3379
|
+
returnChanged =
|
|
3380
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() !==
|
|
3381
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime();
|
|
3382
|
+
} else {
|
|
3383
|
+
returnChanged = true;
|
|
3384
|
+
}
|
|
3385
|
+
} else if (initialReturnDt && selectedReturnDt) {
|
|
3386
|
+
returnChanged =
|
|
3387
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() !==
|
|
3388
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime();
|
|
3389
|
+
} else if (implicitReturnBaselineId != null && selectedReturnId != null) {
|
|
3390
|
+
returnChanged = implicitReturnBaselineId !== selectedReturnId;
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
return {
|
|
3394
|
+
hasChangesFromInitial:
|
|
3395
|
+
dateChanged ||
|
|
3396
|
+
optionChanged ||
|
|
3397
|
+
pickupChanged ||
|
|
3398
|
+
countsChanged ||
|
|
3399
|
+
addOnsChanged ||
|
|
3400
|
+
returnChanged,
|
|
3401
|
+
// Authoritative for "real user change" gating in provider dashboard:
|
|
3402
|
+
// ignore option-id noise and only consider user-visible booking deltas.
|
|
3403
|
+
hasOperationalChangesFromInitial:
|
|
3404
|
+
dateChanged ||
|
|
3405
|
+
pickupChanged ||
|
|
3406
|
+
countsChanged ||
|
|
3407
|
+
addOnsChanged ||
|
|
3408
|
+
returnChanged,
|
|
3409
|
+
dateChanged,
|
|
3410
|
+
// Tickets line corresponds to "option + ticket counts"; add-ons and pickup changes affect itinerary but not the ticket label.
|
|
3411
|
+
ticketsChanged: Boolean(optionChanged || countsChanged),
|
|
3412
|
+
optionChanged,
|
|
3413
|
+
pickupChanged,
|
|
3414
|
+
countsChanged,
|
|
3415
|
+
addOnsChanged,
|
|
3416
|
+
returnChanged,
|
|
3417
|
+
};
|
|
3418
|
+
}, [
|
|
3419
|
+
initialValues,
|
|
3420
|
+
changeFlowResolvedInitialProductOptionId,
|
|
3421
|
+
selectedAvailability,
|
|
3422
|
+
selectedReturnOption,
|
|
3423
|
+
implicitReturnBaselineId,
|
|
3424
|
+
pickupLocationId,
|
|
3425
|
+
quantities,
|
|
3426
|
+
addOnSelections,
|
|
3427
|
+
initialAddOnMinQtyByKey,
|
|
3428
|
+
]);
|
|
3429
|
+
const hasChangeSelection = changeSelectionDetails.hasChangesFromInitial;
|
|
3430
|
+
|
|
3431
|
+
const changeFlowNeedsServerPrice =
|
|
3432
|
+
true &&
|
|
3433
|
+
hasChangeSelection &&
|
|
3434
|
+
!!initialValues?.bookingReference?.trim() &&
|
|
3435
|
+
!!lastName.trim();
|
|
3436
|
+
|
|
3437
|
+
const isChangeQuoteBlocked = true && latestChangeQuote?.canProceed === false;
|
|
3438
|
+
const requiresReturnInChangeFlow = true && !!initialValues?.returnAvailabilityId?.trim();
|
|
3439
|
+
const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
|
|
3440
|
+
|
|
3441
|
+
const changeFlowSubmitDisabled =
|
|
3442
|
+
missingRequiredReturnSelection ||
|
|
3443
|
+
(true &&
|
|
3444
|
+
changeFlowNeedsServerPrice &&
|
|
3445
|
+
(changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)));
|
|
3446
|
+
|
|
3447
|
+
const providerTotalsPreview = null;
|
|
3448
|
+
const hasEffectiveChangeSelection = hasChangeSelection;
|
|
3449
|
+
|
|
3450
|
+
const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
|
|
3451
|
+
providerPreview: providerTotalsPreview,
|
|
3452
|
+
serverQuotePreview:
|
|
3453
|
+
true && latestChangeQuote?.serverDisplay
|
|
3454
|
+
? latestChangeQuote.serverDisplay
|
|
3455
|
+
: null,
|
|
3456
|
+
fromCart: {
|
|
3457
|
+
total: changeFlowNewBookingTotal,
|
|
3458
|
+
subtotal: effectiveSubtotal,
|
|
3459
|
+
tax: effectiveTax,
|
|
3460
|
+
},
|
|
3461
|
+
});
|
|
3462
|
+
const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
|
|
3463
|
+
const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
|
|
3464
|
+
const displayChangeFlowTax = displayedChangeAmounts.tax;
|
|
3465
|
+
|
|
3466
|
+
const changeFlowClientEstimateDue = (() => {
|
|
3467
|
+
if (!originalReceipt) return totalPrice;
|
|
3468
|
+
// Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
|
|
3469
|
+
if (true && latestChangeQuote != null && !changeQuoteFetchError) {
|
|
3470
|
+
return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
|
|
3471
|
+
}
|
|
3472
|
+
return changeFlowBalanceVsOriginal({
|
|
3473
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
3474
|
+
originalReceiptTotal: originalReceipt.total,
|
|
3475
|
+
audience: 'customer',
|
|
3476
|
+
});
|
|
3477
|
+
})();
|
|
3478
|
+
|
|
3479
|
+
const changeFlowAmountDueRaw = changeFlowClientEstimateDue;
|
|
3480
|
+
const changeFlowAmountDue = normalizeNearZeroOwed(changeFlowAmountDueRaw);
|
|
3481
|
+
|
|
3482
|
+
const changeCheckoutButtonLabel = (() => {
|
|
3483
|
+
if (!hasEffectiveChangeSelection) return undefined;
|
|
3484
|
+
if (changeFlowNeedsServerPrice) {
|
|
3485
|
+
if (changeQuoteLoading) {
|
|
3486
|
+
const tr = t('booking.updatingPrice');
|
|
3487
|
+
return tr !== 'booking.updatingPrice' ? tr : 'Updating price…';
|
|
3488
|
+
}
|
|
3489
|
+
if (changeQuoteFetchError) {
|
|
3490
|
+
const tr = t('booking.changeBookingRetry');
|
|
3491
|
+
return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
|
|
3492
|
+
}
|
|
3493
|
+
if (isChangeQuoteBlocked) {
|
|
3494
|
+
const tr = t('booking.changeBookingNotAvailable');
|
|
3495
|
+
return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
|
|
3496
|
+
}
|
|
3497
|
+
if (latestChangeQuote) {
|
|
3498
|
+
const d = Math.round(changeFlowClientEstimateDue * 100) / 100;
|
|
3499
|
+
return d > 0
|
|
3500
|
+
? `Change booking (${formatCurrencyAmount(d, currency, locale as 'en' | 'fr')})`
|
|
3501
|
+
: 'Change booking (no charge)';
|
|
3502
|
+
}
|
|
3503
|
+
const tr = t('booking.changeBooking');
|
|
3504
|
+
return tr !== 'booking.changeBooking' ? tr : 'Change booking';
|
|
3505
|
+
}
|
|
3506
|
+
const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
|
|
3507
|
+
return est > 0
|
|
3508
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3509
|
+
: 'Change booking (no charge)';
|
|
3510
|
+
})();
|
|
3511
|
+
/** Partner deferred-invoice path applies to {@link NewBookingFlow} only. */
|
|
3512
|
+
const deferredInvoiceSubmitLabel = undefined;
|
|
3513
|
+
|
|
3514
|
+
const checkoutFormError =
|
|
3515
|
+
(error || '') ||
|
|
3516
|
+
(missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
|
|
3517
|
+
(true && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
3518
|
+
(true ? changeQuoteFetchError ?? '' : '');
|
|
3519
|
+
|
|
3520
|
+
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
3521
|
+
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
3522
|
+
const ticketsLine = formatTicketLineItemsForSummary(
|
|
3523
|
+
ticketLineItems.map((l) => ({ category: l.category, qty: l.qty }))
|
|
3524
|
+
);
|
|
3525
|
+
const itinerary = computeItineraryDisplay();
|
|
3526
|
+
const itinerarySteps =
|
|
3527
|
+
itinerary?.map((s) => ({
|
|
3528
|
+
time: s.time?.trim() || null,
|
|
3529
|
+
label: getItineraryStepLabel(s),
|
|
3530
|
+
})) ?? [];
|
|
3531
|
+
return {
|
|
3532
|
+
tourName: product.name?.trim() || '',
|
|
3533
|
+
dateTime: selectedAvailability.dateTime,
|
|
3534
|
+
ticketsLine,
|
|
3535
|
+
itinerarySteps,
|
|
3536
|
+
dateChanged: changeSelectionDetails.dateChanged,
|
|
3537
|
+
ticketsChanged: changeSelectionDetails.ticketsChanged,
|
|
3538
|
+
hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
|
|
3539
|
+
selectionTotal:
|
|
3540
|
+
originalReceipt
|
|
3541
|
+
? suppressSelfServeCurrencyUi && !selfServePricingConfirmed
|
|
3542
|
+
? null
|
|
3543
|
+
: displayChangeFlowProposedTotal
|
|
3544
|
+
: totalPrice,
|
|
3545
|
+
selectionCurrency: currency,
|
|
3546
|
+
};
|
|
3547
|
+
}, [
|
|
3548
|
+
selectedAvailability,
|
|
3549
|
+
totalQuantity,
|
|
3550
|
+
ticketLineItems,
|
|
3551
|
+
computeItineraryDisplay,
|
|
3552
|
+
product.name,
|
|
3553
|
+
changeSelectionDetails,
|
|
3554
|
+
totalPrice,
|
|
3555
|
+
currency,
|
|
3556
|
+
originalReceipt,
|
|
3557
|
+
displayChangeFlowProposedTotal,
|
|
3558
|
+
suppressSelfServeCurrencyUi,
|
|
3559
|
+
selfServePricingConfirmed,
|
|
3560
|
+
]);
|
|
3561
|
+
|
|
3562
|
+
useEffect(() => {
|
|
3563
|
+
if (!onChangeFlowSelectionPreview) return;
|
|
3564
|
+
const next = changeFlowSelectionPreview;
|
|
3565
|
+
const key =
|
|
3566
|
+
next === null
|
|
3567
|
+
? 'null'
|
|
3568
|
+
: JSON.stringify({
|
|
3569
|
+
tourName: next.tourName,
|
|
3570
|
+
dateTime: next.dateTime,
|
|
3571
|
+
ticketsLine: next.ticketsLine,
|
|
3572
|
+
itinerarySteps: next.itinerarySteps,
|
|
3573
|
+
dateChanged: next.dateChanged,
|
|
3574
|
+
ticketsChanged: next.ticketsChanged,
|
|
3575
|
+
hasChangesFromInitial: next.hasChangesFromInitial,
|
|
3576
|
+
selectionTotal: next.selectionTotal,
|
|
3577
|
+
selectionCurrency: next.selectionCurrency,
|
|
3578
|
+
});
|
|
3579
|
+
if (key === lastChangeFlowPreviewKeyRef.current) return;
|
|
3580
|
+
lastChangeFlowPreviewKeyRef.current = key;
|
|
3581
|
+
onChangeFlowSelectionPreview(next);
|
|
3582
|
+
}, [changeFlowSelectionPreview, onChangeFlowSelectionPreview]);
|
|
3583
|
+
|
|
3584
|
+
useEffect(() => {
|
|
3585
|
+
if (!onPricePreviewChange) return;
|
|
3586
|
+
if (!selectedAvailability || totalQuantity <= 0) {
|
|
3587
|
+
onPricePreviewChange(null);
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
if (suppressSelfServeCurrencyUi && !selfServePricingConfirmed) {
|
|
3591
|
+
onPricePreviewChange(null);
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
onPricePreviewChange({
|
|
3595
|
+
subtotal: originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
|
|
3596
|
+
tax:
|
|
3597
|
+
!isTaxIncludedInPrice
|
|
3598
|
+
? originalReceipt
|
|
3599
|
+
? displayChangeFlowTax
|
|
3600
|
+
: effectiveTax
|
|
3601
|
+
: 0,
|
|
3602
|
+
total: originalReceipt ? displayChangeFlowProposedTotal : totalPrice,
|
|
3603
|
+
currency,
|
|
3604
|
+
});
|
|
3605
|
+
}, [
|
|
3606
|
+
onPricePreviewChange,
|
|
3607
|
+
selectedAvailability,
|
|
3608
|
+
totalQuantity,
|
|
3609
|
+
effectiveSubtotal,
|
|
3610
|
+
effectiveTax,
|
|
3611
|
+
displayChangeFlowProposedTotal,
|
|
3612
|
+
displayChangeFlowSubtotal,
|
|
3613
|
+
displayChangeFlowTax,
|
|
3614
|
+
currency,
|
|
3615
|
+
isTaxIncludedInPrice,
|
|
3616
|
+
originalReceipt,
|
|
3617
|
+
totalPrice,
|
|
3618
|
+
suppressSelfServeCurrencyUi,
|
|
3619
|
+
selfServePricingConfirmed,
|
|
3620
|
+
]);
|
|
3621
|
+
|
|
3622
|
+
/** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
|
|
3623
|
+
useEffect(() => {
|
|
3624
|
+
if (false) {
|
|
3625
|
+
setChangeQuoteLoading(false);
|
|
3626
|
+
setChangeQuoteFetchError(null);
|
|
3627
|
+
setLatestChangeQuote(null);
|
|
3628
|
+
return;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
if (
|
|
3632
|
+
!hasChangeSelection ||
|
|
3633
|
+
!selectedAvailability ||
|
|
3634
|
+
!initialValues?.bookingReference?.trim() ||
|
|
3635
|
+
!lastName.trim() ||
|
|
3636
|
+
missingRequiredReturnSelection
|
|
3637
|
+
) {
|
|
3638
|
+
setLatestChangeQuote(null);
|
|
3639
|
+
setChangeQuoteLoading(false);
|
|
3640
|
+
setChangeQuoteFetchError(null);
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
const optionId =
|
|
3645
|
+
selectedAvailability.productOptionId?.trim() || activeOptions[0]?.optionId;
|
|
3646
|
+
if (!optionId) {
|
|
3647
|
+
setLatestChangeQuote(null);
|
|
3648
|
+
setChangeQuoteLoading(false);
|
|
3649
|
+
setChangeQuoteFetchError(null);
|
|
3650
|
+
return;
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
setChangeQuoteLoading(true);
|
|
3654
|
+
setChangeQuoteFetchError(null);
|
|
3655
|
+
const seq = ++changeQuoteRequestSeq.current;
|
|
3656
|
+
const bookingReferenceForQuote = initialValues.bookingReference.trim();
|
|
3657
|
+
const timer = window.setTimeout(() => {
|
|
3658
|
+
void (async () => {
|
|
3659
|
+
const bookingItems = Object.entries(quantities)
|
|
3660
|
+
.filter(([, count]) => count > 0)
|
|
3661
|
+
.map(([category, count]) => ({ category, count }));
|
|
3662
|
+
try {
|
|
3663
|
+
const quote = await quoteChangeBooking({
|
|
3664
|
+
bookingReference: bookingReferenceForQuote,
|
|
3665
|
+
lastName: lastName.trim(),
|
|
3666
|
+
newProductId: optionId,
|
|
3667
|
+
newDateTime: selectedAvailability.dateTime,
|
|
3668
|
+
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
3669
|
+
newPickupLocationId: pickupLocationId || null,
|
|
3670
|
+
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3671
|
+
newPassengerCounts: bookingItems,
|
|
3672
|
+
// Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
|
|
3673
|
+
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3674
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
3675
|
+
capacitySeatCredit: {
|
|
3676
|
+
enabled: true,
|
|
3677
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
3678
|
+
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
3679
|
+
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
3680
|
+
},
|
|
3681
|
+
});
|
|
3682
|
+
if (seq !== changeQuoteRequestSeq.current) return;
|
|
3683
|
+
const slice = sliceChangeQuoteForUi(
|
|
3684
|
+
quote,
|
|
3685
|
+
{
|
|
3686
|
+
total: changeFlowNewBookingTotal,
|
|
3687
|
+
subtotal: effectiveSubtotal,
|
|
3688
|
+
tax: effectiveTax,
|
|
3689
|
+
},
|
|
3690
|
+
currency
|
|
3691
|
+
);
|
|
3692
|
+
setLatestChangeQuote(
|
|
3693
|
+
mergeQuoteSliceWithServerPreview(slice, quote, {
|
|
3694
|
+
total: changeFlowNewBookingTotal,
|
|
3695
|
+
subtotal: effectiveSubtotal,
|
|
3696
|
+
tax: effectiveTax,
|
|
3697
|
+
}, currency),
|
|
3698
|
+
);
|
|
3699
|
+
} catch (e) {
|
|
3700
|
+
if (seq !== changeQuoteRequestSeq.current) return;
|
|
3701
|
+
setLatestChangeQuote(null);
|
|
3702
|
+
setChangeQuoteFetchError(e instanceof Error ? e.message : 'Failed to get price for this change');
|
|
3703
|
+
} finally {
|
|
3704
|
+
if (seq === changeQuoteRequestSeq.current) {
|
|
3705
|
+
setChangeQuoteLoading(false);
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
})();
|
|
3709
|
+
}, 450);
|
|
3710
|
+
|
|
3711
|
+
return () => {
|
|
3712
|
+
window.clearTimeout(timer);
|
|
3713
|
+
};
|
|
3714
|
+
}, [
|
|
3715
|
+
true,
|
|
3716
|
+
hasChangeSelection,
|
|
3717
|
+
selectedAvailability,
|
|
3718
|
+
selectedAvailability?.dateTime,
|
|
3719
|
+
selectedAvailability?.productOptionId,
|
|
3720
|
+
initialValues?.bookingReference,
|
|
3721
|
+
lastName,
|
|
3722
|
+
missingRequiredReturnSelection,
|
|
3723
|
+
pickupLocationId,
|
|
3724
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
3725
|
+
quantities,
|
|
3726
|
+
addOnSelections,
|
|
3727
|
+
changeFlowInitialTicketCount,
|
|
3728
|
+
changeFlowNewBookingTotal,
|
|
3729
|
+
effectiveSubtotal,
|
|
3730
|
+
effectiveTax,
|
|
3731
|
+
totalPrice,
|
|
3732
|
+
currency,
|
|
3733
|
+
activeOptions,
|
|
3734
|
+
initialValues?.availabilityId,
|
|
3735
|
+
initialValues?.returnAvailabilityId,
|
|
3736
|
+
]);
|
|
3737
|
+
|
|
3738
|
+
// Auto-select product option when date is selected: most popular if set, otherwise first available.
|
|
3739
|
+
// Change flow after a calendar change: prefer same product option, then closest departure time-of-day.
|
|
3740
|
+
useEffect(() => {
|
|
3741
|
+
if (selectedDate && timesForSelectedDate.length > 0 && !selectedAvailability) {
|
|
3742
|
+
// Change flow on the booking's calendar day: pickup must come from resolveInitialAvailabilityFromBooking
|
|
3743
|
+
// (availabilityId / productOptionId / wall time). If we run "most popular" here too, both effects fire in
|
|
3744
|
+
// the same commit, this still sees selectedAvailability === null, and overwrites the correct slot.
|
|
3745
|
+
if (initialValues?.dateTime?.trim()) {
|
|
3746
|
+
try {
|
|
3747
|
+
const bookingDay = formatInTimeZone(
|
|
3748
|
+
parseAvailabilityDateTime(initialValues.dateTime.trim()),
|
|
3749
|
+
companyTimezone,
|
|
3750
|
+
'yyyy-MM-dd'
|
|
3751
|
+
);
|
|
3752
|
+
if (selectedDate === bookingDay) {
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
} catch {
|
|
3756
|
+
/* fall through */
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
const anchor = changeFlowOutboundAnchorRef.current;
|
|
3761
|
+
if (anchor) {
|
|
3762
|
+
const matched = pickOutboundMatchingPreviousSelection(
|
|
3763
|
+
timesForSelectedDate,
|
|
3764
|
+
anchor,
|
|
3765
|
+
companyTimezone,
|
|
3766
|
+
optionsMap
|
|
3767
|
+
);
|
|
3768
|
+
changeFlowOutboundAnchorRef.current = null;
|
|
3769
|
+
if (matched) {
|
|
3770
|
+
setSelectedAvailability(matched);
|
|
3771
|
+
setError('');
|
|
3772
|
+
return;
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
const mostPopularOption = activeOptions.find(opt => opt.mostPopular);
|
|
3777
|
+
const candidate = mostPopularOption
|
|
3778
|
+
? timesForSelectedDate.find(avail => avail.productOptionId === mostPopularOption.optionId && avail.vacancies > 0)
|
|
3779
|
+
: null;
|
|
3780
|
+
const fallback = timesForSelectedDate.find(avail => avail.vacancies > 0);
|
|
3781
|
+
const toSelect = candidate ?? fallback;
|
|
3782
|
+
if (toSelect) {
|
|
3783
|
+
setSelectedAvailability(toSelect);
|
|
3784
|
+
setError('');
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
}, [
|
|
3788
|
+
selectedDate,
|
|
3789
|
+
timesForSelectedDateSelectionKey,
|
|
3790
|
+
activeOptionIdsKey,
|
|
3791
|
+
selectedAvailability,
|
|
3792
|
+
initialValues?.dateTime,
|
|
3793
|
+
companyTimezone,
|
|
3794
|
+
]);
|
|
3795
|
+
|
|
3796
|
+
// Currency change does NOT trigger a refetch. Backend returns per-currency data (priceByCurrency,
|
|
3797
|
+
// changeByCurrency, feesByCurrency, precomputedPrices, etc.) in one response; we just
|
|
3798
|
+
// re-render with the new currency and pick the right values.
|
|
3799
|
+
|
|
3800
|
+
// Sync selectedAvailability when the availabilities list changes (e.g. after refetch for new date range)
|
|
3801
|
+
useEffect(() => {
|
|
3802
|
+
if (selectedAvailability && availabilities.length > 0) {
|
|
3803
|
+
const updatedAvailability = availabilities.find(
|
|
3804
|
+
avail =>
|
|
3805
|
+
avail.dateTime === selectedAvailability.dateTime &&
|
|
3806
|
+
avail.productOptionId === selectedAvailability.productOptionId
|
|
3807
|
+
);
|
|
3808
|
+
if (updatedAvailability) {
|
|
3809
|
+
setSelectedAvailability(updatedAvailability);
|
|
3810
|
+
|
|
3811
|
+
// Also update selectedReturnOption if it exists
|
|
3812
|
+
if (selectedReturnOption && updatedAvailability.returnOptions) {
|
|
3813
|
+
const updatedReturnOption = updatedAvailability.returnOptions.find(
|
|
3814
|
+
opt => opt.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
3815
|
+
);
|
|
3816
|
+
if (updatedReturnOption) {
|
|
3817
|
+
setSelectedReturnOption(updatedReturnOption);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3823
|
+
}, [availabilities]); // Update when availabilities change (selectedAvailability/selectedReturnOption intentionally excluded to avoid loops)
|
|
3824
|
+
|
|
3825
|
+
// Reset implicit return baseline when outbound changes or API provides explicit return hints.
|
|
3826
|
+
useEffect(() => {
|
|
3827
|
+
const hasApiReturnHint =
|
|
3828
|
+
Boolean(initialValues?.returnAvailabilityId?.trim()) ||
|
|
3829
|
+
Boolean(initialValues?.returnDateTime?.trim());
|
|
3830
|
+
if (hasApiReturnHint) {
|
|
3831
|
+
setImplicitReturnBaselineId(null);
|
|
3832
|
+
return;
|
|
3833
|
+
}
|
|
3834
|
+
setImplicitReturnBaselineId(null);
|
|
3835
|
+
}, [
|
|
3836
|
+
selectedAvailability?.dateTime,
|
|
3837
|
+
selectedAvailability?.productOptionId,
|
|
3838
|
+
initialValues?.returnAvailabilityId,
|
|
3839
|
+
initialValues?.returnDateTime,
|
|
3840
|
+
]);
|
|
3841
|
+
|
|
3842
|
+
// Auto-select return option when outbound is selected (prefer booked return in change flow).
|
|
3843
|
+
// After a calendar date change in change flow: prefer same return location, then closest return time-of-day.
|
|
3844
|
+
useEffect(() => {
|
|
3845
|
+
if (selectedAvailability?.returnOptions && selectedAvailability.returnOptions.length > 0 && !selectedReturnOption) {
|
|
3846
|
+
const sorted = [...selectedAvailability.returnOptions].sort(
|
|
3847
|
+
(a, b) => parseISO(a.dateTime).getTime() - parseISO(b.dateTime).getTime()
|
|
3848
|
+
);
|
|
3849
|
+
const initialReturnIdForSelect = initialValues?.returnAvailabilityId?.trim();
|
|
3850
|
+
const initialReturnDtForSelect = initialValues?.returnDateTime?.trim();
|
|
3851
|
+
|
|
3852
|
+
const returnAnchor = changeFlowReturnAnchorRef.current;
|
|
3853
|
+
if (returnAnchor) {
|
|
3854
|
+
const fromAnchor = pickReturnMatchingPreviousSelection(
|
|
3855
|
+
sorted,
|
|
3856
|
+
returnAnchor,
|
|
3857
|
+
companyTimezone
|
|
3858
|
+
);
|
|
3859
|
+
changeFlowReturnAnchorRef.current = null;
|
|
3860
|
+
if (fromAnchor) {
|
|
3861
|
+
setSelectedReturnOption(fromAnchor);
|
|
3862
|
+
const hasApiReturnHintFromAnchor =
|
|
3863
|
+
Boolean(initialReturnIdForSelect) || Boolean(initialReturnDtForSelect);
|
|
3864
|
+
if (!hasApiReturnHintFromAnchor) {
|
|
3865
|
+
setImplicitReturnBaselineId((prev) => prev ?? fromAnchor.returnAvailabilityId);
|
|
3866
|
+
}
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
const preferBooked =
|
|
3872
|
+
initialReturnIdForSelect
|
|
3873
|
+
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
|
|
3874
|
+
: undefined;
|
|
3875
|
+
const preferByDateTime =
|
|
3876
|
+
initialReturnDtForSelect && !preferBooked
|
|
3877
|
+
? sorted.find((opt) => {
|
|
3878
|
+
try {
|
|
3879
|
+
return (
|
|
3880
|
+
parseAvailabilityDateTime(opt.dateTime).getTime() ===
|
|
3881
|
+
parseAvailabilityDateTime(initialReturnDtForSelect).getTime()
|
|
3882
|
+
);
|
|
3883
|
+
} catch {
|
|
3884
|
+
return false;
|
|
3885
|
+
}
|
|
3886
|
+
})
|
|
3887
|
+
: undefined;
|
|
3888
|
+
const firstAvailable = sorted.find((opt) => opt.vacancies > 0);
|
|
3889
|
+
const toSelect = preferBooked ?? preferByDateTime ?? firstAvailable;
|
|
3890
|
+
if (toSelect) {
|
|
3891
|
+
setSelectedReturnOption(toSelect);
|
|
3892
|
+
const hasApiReturnHintToSelect =
|
|
3893
|
+
Boolean(initialReturnIdForSelect) || Boolean(initialReturnDtForSelect);
|
|
3894
|
+
if (!hasApiReturnHintToSelect) {
|
|
3895
|
+
setImplicitReturnBaselineId((prev) => prev ?? toSelect.returnAvailabilityId);
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
}, [
|
|
3900
|
+
selectedAvailability,
|
|
3901
|
+
selectedReturnOption,
|
|
3902
|
+
companyTimezone,
|
|
3903
|
+
false,
|
|
3904
|
+
initialValues?.returnAvailabilityId,
|
|
3905
|
+
initialValues?.returnDateTime,
|
|
3906
|
+
]);
|
|
3907
|
+
|
|
3908
|
+
// Fetch add-ons when availability (product option) is selected; clear selections when option changes
|
|
3909
|
+
const availabilityProductOptionId = selectedAvailability?.productOptionId ?? null;
|
|
3910
|
+
const prevAvailabilityProductOptionIdRef = useRef<string | null>(null);
|
|
3911
|
+
useEffect(() => {
|
|
3912
|
+
if (!availabilityProductOptionId || !product.companyId) {
|
|
3913
|
+
setAddOns([]);
|
|
3914
|
+
return;
|
|
3915
|
+
}
|
|
3916
|
+
const optionChanged = prevAvailabilityProductOptionIdRef.current !== availabilityProductOptionId;
|
|
3917
|
+
if (optionChanged) {
|
|
3918
|
+
prevAvailabilityProductOptionIdRef.current = availabilityProductOptionId;
|
|
3919
|
+
}
|
|
3920
|
+
getAddOns(product.companyId, { productOptionId: availabilityProductOptionId, preCheckout: true })
|
|
3921
|
+
.then(setAddOns)
|
|
3922
|
+
.catch(() => setAddOns([]));
|
|
3923
|
+
}, [availabilityProductOptionId, product.companyId]);
|
|
3924
|
+
|
|
3925
|
+
const handleDateSelect = (date: string) => {
|
|
3926
|
+
if (date === selectedDate) return;
|
|
3927
|
+
if (selectedAvailability) {
|
|
3928
|
+
changeFlowOutboundAnchorRef.current = {
|
|
3929
|
+
productOptionId: selectedAvailability.productOptionId ?? null,
|
|
3930
|
+
minutesFromMidnight: getMinutesFromMidnightInTimezone(
|
|
3931
|
+
selectedAvailability.dateTime,
|
|
3932
|
+
companyTimezone
|
|
3933
|
+
),
|
|
3934
|
+
};
|
|
3935
|
+
if (selectedReturnOption) {
|
|
3936
|
+
changeFlowReturnAnchorRef.current = {
|
|
3937
|
+
returnLocation: selectedReturnOption.returnLocation,
|
|
3938
|
+
minutesFromMidnight: getMinutesFromMidnightInTimezone(
|
|
3939
|
+
selectedReturnOption.dateTime,
|
|
3940
|
+
companyTimezone
|
|
3941
|
+
),
|
|
3942
|
+
};
|
|
3943
|
+
} else {
|
|
3944
|
+
changeFlowReturnAnchorRef.current = null;
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
setSelectedAvailability(null);
|
|
3948
|
+
setSelectedReturnOption(null);
|
|
3949
|
+
};
|
|
3950
|
+
|
|
3951
|
+
handleDateSelectRef.current = handleDateSelect;
|
|
3952
|
+
|
|
3953
|
+
useEffect(() => {
|
|
3954
|
+
if (flowUi?.autoSelectFirstAvailableDate !== true) return;
|
|
3955
|
+
if (isPartialLaunch) return;
|
|
3956
|
+
if (initialValues?.dateTime?.trim()) return;
|
|
3957
|
+
if (selectedDate !== '') return;
|
|
3958
|
+
if (dates.length === 0) return;
|
|
3959
|
+
if (hasAutoSelectedPartnerDateRef.current) return;
|
|
3960
|
+
|
|
3961
|
+
// Match Calendar: first day where at least one departure can fit the party (self-serve change),
|
|
3962
|
+
// else first day with any inventory; admin fallback unchanged.
|
|
3963
|
+
const firstWithInventory = dates.find((d) => {
|
|
3964
|
+
const rows = availabilitiesByDate[d] ?? [];
|
|
3965
|
+
if (rows.length === 0) return false;
|
|
3966
|
+
if (false) return rows.some((a) => (a.vacancies ?? 0) > 0);
|
|
3967
|
+
if (
|
|
3968
|
+
true &&
|
|
3969
|
+
changeFlowInitialTicketCount > 0
|
|
3970
|
+
) {
|
|
3971
|
+
return rows.some(
|
|
3972
|
+
(a) => getCalendarEffectiveOutboundVacancies(a) >= changeFlowInitialTicketCount,
|
|
3973
|
+
);
|
|
3974
|
+
}
|
|
3975
|
+
return rows.some((a) => (a.vacancies ?? 0) > 0);
|
|
3976
|
+
});
|
|
3977
|
+
const first = firstWithInventory ?? (false && dates[0] ? dates[0] : undefined);
|
|
3978
|
+
if (!first) return;
|
|
3979
|
+
|
|
3980
|
+
hasAutoSelectedPartnerDateRef.current = true;
|
|
3981
|
+
setSelectedDate(first);
|
|
3982
|
+
handleDateSelectRef.current(first);
|
|
3983
|
+
if (!suppressCalendarDateScroll) {
|
|
3984
|
+
setTimeout(() => {
|
|
3985
|
+
const container = contentRef?.current;
|
|
3986
|
+
if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
|
|
3987
|
+
container.scrollBy({ top: 400, behavior: 'smooth' });
|
|
3988
|
+
} else if (typeof window !== 'undefined') {
|
|
3989
|
+
window.scrollBy({ top: 400, behavior: 'smooth' });
|
|
3990
|
+
}
|
|
3991
|
+
}, 100);
|
|
3992
|
+
}
|
|
3993
|
+
}, [
|
|
3994
|
+
flowUi?.autoSelectFirstAvailableDate,
|
|
3995
|
+
isPartialLaunch,
|
|
3996
|
+
initialValues?.dateTime,
|
|
3997
|
+
selectedDate,
|
|
3998
|
+
dates,
|
|
3999
|
+
availabilitiesByDate,
|
|
4000
|
+
false,
|
|
4001
|
+
true,
|
|
4002
|
+
changeFlowInitialTicketCount,
|
|
4003
|
+
getCalendarEffectiveOutboundVacancies,
|
|
4004
|
+
useWindowScroll,
|
|
4005
|
+
contentRef,
|
|
4006
|
+
suppressCalendarDateScroll,
|
|
4007
|
+
]);
|
|
4008
|
+
|
|
4009
|
+
useEffect(() => {
|
|
4010
|
+
if (flowUi?.autoSelectFirstHighlightedPickup !== true) return;
|
|
4011
|
+
if (hasAutoSelectedPartnerPickupRef.current) return;
|
|
4012
|
+
if (!highlightedPickupLocationIds?.length) return;
|
|
4013
|
+
if (pickupLocationId) return;
|
|
4014
|
+
if (pickupLocationSkipped) return;
|
|
4015
|
+
if (initialValues?.pickupLocationId?.trim()) return;
|
|
4016
|
+
const locs = product.pickupLocations;
|
|
4017
|
+
if (!locs?.length) return;
|
|
4018
|
+
const match = highlightedPickupLocationIds.find((id) => locs.some((l) => l.id === id));
|
|
4019
|
+
if (!match) return;
|
|
4020
|
+
hasAutoSelectedPartnerPickupRef.current = true;
|
|
4021
|
+
setPickupLocationId(match);
|
|
4022
|
+
setPickupLocationSkipped(false);
|
|
4023
|
+
}, [
|
|
4024
|
+
flowUi?.autoSelectFirstHighlightedPickup,
|
|
4025
|
+
highlightedPickupLocationIds,
|
|
4026
|
+
pickupLocationId,
|
|
4027
|
+
pickupLocationSkipped,
|
|
4028
|
+
initialValues?.pickupLocationId,
|
|
4029
|
+
product.pickupLocations,
|
|
4030
|
+
]);
|
|
4031
|
+
|
|
4032
|
+
const handleTimeSelect = (availability: Availability) => {
|
|
4033
|
+
setSelectedAvailability(availability);
|
|
4034
|
+
setSelectedReturnOption(null); // Clear return selection when changing start time
|
|
4035
|
+
setError('');
|
|
4036
|
+
};
|
|
4037
|
+
|
|
4038
|
+
const handleQuantityChange = (category: string, delta: number) => {
|
|
4039
|
+
const maxAvailable = false ? Number.MAX_SAFE_INTEGER : effectivePartySizeCap;
|
|
4040
|
+
const currentQty = quantities[category] || 0;
|
|
4041
|
+
const minQ =
|
|
4042
|
+
changeBookingMinimumQuantities != null
|
|
4043
|
+
? Math.max(0, changeBookingMinimumQuantities[category] ?? 0)
|
|
4044
|
+
: 0;
|
|
4045
|
+
const newQty = Math.max(minQ, currentQty + delta);
|
|
4046
|
+
// Admin can overbook; non-admin cannot exceed vacancies
|
|
4047
|
+
if (delta > 0 && !false && orderSummary.totalQuantity >= maxAvailable) {
|
|
4048
|
+
return;
|
|
4049
|
+
}
|
|
4050
|
+
setQuantities(prev => ({
|
|
4051
|
+
...prev,
|
|
4052
|
+
[category]: newQty,
|
|
4053
|
+
}));
|
|
4054
|
+
setError('');
|
|
4055
|
+
};
|
|
4056
|
+
|
|
4057
|
+
// Selected availability has a deal applied (promo codes not allowed with deals; vouchers/gift cards still allowed; dynamic pricing alone is ok)
|
|
4058
|
+
const hasOngoingDiscount = useMemo(
|
|
4059
|
+
() =>
|
|
4060
|
+
selectedAvailability?.rates?.some((r) =>
|
|
4061
|
+
(r.appliedAdjustments ?? r.applied_adjustments ?? []).some((a) => (a.type ?? '').toLowerCase() === 'deal')
|
|
4062
|
+
) ?? false,
|
|
4063
|
+
[selectedAvailability]
|
|
4064
|
+
);
|
|
4065
|
+
const selectedAvailabilityKey = useMemo(
|
|
4066
|
+
() => `${selectedAvailability?.dateTime ?? ''}::${selectedAvailability?.productOptionId ?? ''}`,
|
|
4067
|
+
[selectedAvailability?.dateTime, selectedAvailability?.productOptionId]
|
|
4068
|
+
);
|
|
4069
|
+
// Remember where promo was successfully applied to avoid self-clearing on same selection.
|
|
4070
|
+
const promoAppliedSelectionKeyRef = useRef<string | null>(null);
|
|
4071
|
+
const promoValidateInFlightRef = useRef(false);
|
|
4072
|
+
|
|
4073
|
+
const handleApplyPromo = useCallback(async () => {
|
|
4074
|
+
const code = promoCodeInput.trim().toUpperCase();
|
|
4075
|
+
if (!code) return;
|
|
4076
|
+
// Promo validation/application requires a concrete booking context.
|
|
4077
|
+
if (!selectedAvailability || totalQuantity <= 0) {
|
|
4078
|
+
return;
|
|
4079
|
+
}
|
|
4080
|
+
if (appliedPromoCode === code) return; // Already applied, skip API call
|
|
4081
|
+
const companyId = product.companyId;
|
|
4082
|
+
if (!companyId) return;
|
|
4083
|
+
if (promoValidateInFlightRef.current) return;
|
|
4084
|
+
promoValidateInFlightRef.current = true;
|
|
4085
|
+
setPromoCodeError('');
|
|
4086
|
+
setPromoCodeValidating(true);
|
|
4087
|
+
try {
|
|
4088
|
+
const result = await validatePromoCode(code, companyId, product.productId, hasOngoingDiscount);
|
|
4089
|
+
if (result.valid) {
|
|
4090
|
+
promoAppliedSelectionKeyRef.current = selectedAvailabilityKey;
|
|
4091
|
+
setAppliedPromoCode(code);
|
|
4092
|
+
fetchedRangesRef.current = [];
|
|
4093
|
+
} else {
|
|
4094
|
+
const errorMsg =
|
|
4095
|
+
result.error === 'Promo codes cannot be stacked with deals'
|
|
4096
|
+
? (t('booking.promoCodesCannotStackWithDiscounts') || result.error)
|
|
4097
|
+
: (result.error || t('booking.invalidPromoCode') || 'Invalid or expired promo code');
|
|
4098
|
+
setPromoCodeError(errorMsg);
|
|
4099
|
+
}
|
|
4100
|
+
} catch (err) {
|
|
4101
|
+
setPromoCodeError(err instanceof Error ? err.message : 'Failed to validate promo code');
|
|
4102
|
+
} finally {
|
|
4103
|
+
promoValidateInFlightRef.current = false;
|
|
4104
|
+
setPromoCodeValidating(false);
|
|
4105
|
+
}
|
|
4106
|
+
}, [promoCodeInput, appliedPromoCode, product.companyId, product.productId, hasOngoingDiscount, t, selectedAvailabilityKey, selectedAvailability, totalQuantity]);
|
|
4107
|
+
|
|
4108
|
+
// Ref to avoid effect re-running when handleApplyPromo identity changes (t changes every render)
|
|
4109
|
+
const handleApplyPromoRef = useRef(handleApplyPromo);
|
|
4110
|
+
handleApplyPromoRef.current = handleApplyPromo;
|
|
4111
|
+
|
|
4112
|
+
// Auto-apply promo when user stops typing (mobile-friendly, no Enter key needed).
|
|
4113
|
+
// Do not depend on promoCodeValidating: when it flips false after a failed validate, that would
|
|
4114
|
+
// re-run this effect and schedule another timer → repeated /validate for the same bad code.
|
|
4115
|
+
useEffect(() => {
|
|
4116
|
+
const trimmed = promoCodeInput.trim().toUpperCase();
|
|
4117
|
+
if (!trimmed) return;
|
|
4118
|
+
if (!selectedAvailability || totalQuantity <= 0) return;
|
|
4119
|
+
if (appliedPromoCode === trimmed) return;
|
|
4120
|
+
|
|
4121
|
+
const timer = setTimeout(() => {
|
|
4122
|
+
handleApplyPromoRef.current();
|
|
4123
|
+
}, 600);
|
|
4124
|
+
|
|
4125
|
+
return () => clearTimeout(timer);
|
|
4126
|
+
}, [promoCodeInput, appliedPromoCode, selectedAvailability, totalQuantity]);
|
|
4127
|
+
|
|
4128
|
+
const cancelPendingReservation = useCallback(() => {
|
|
4129
|
+
if (paymentSubmitInFlightRef.current) return;
|
|
4130
|
+
const pending = pendingReservationRef.current;
|
|
4131
|
+
if (pending) {
|
|
4132
|
+
pendingReservationRef.current = null;
|
|
4133
|
+
cancelReservation(pending.reservationReference).catch(() => {});
|
|
4134
|
+
}
|
|
4135
|
+
// Paid change booking opens checkout without a reservation hold — still must dismiss the modal.
|
|
4136
|
+
setShowCheckoutModal(false);
|
|
4137
|
+
setCheckoutModalData(null);
|
|
4138
|
+
setCheckoutClientSecret('');
|
|
4139
|
+
}, []);
|
|
4140
|
+
|
|
4141
|
+
const cancelPendingReservationBestEffort = useCallback(() => {
|
|
4142
|
+
if (paymentSubmitInFlightRef.current) return;
|
|
4143
|
+
const pending = pendingReservationRef.current;
|
|
4144
|
+
if (pending) {
|
|
4145
|
+
pendingReservationRef.current = null;
|
|
4146
|
+
cancelReservationBestEffort(pending.reservationReference);
|
|
4147
|
+
}
|
|
4148
|
+
setShowCheckoutModal(false);
|
|
4149
|
+
setCheckoutModalData(null);
|
|
4150
|
+
setCheckoutClientSecret('');
|
|
4151
|
+
}, []);
|
|
4152
|
+
|
|
4153
|
+
// Parent surfaces (dialog close / embedded back) emit this when user abandons the booking flow.
|
|
4154
|
+
useEffect(() => {
|
|
4155
|
+
const handleAbandon = () => {
|
|
4156
|
+
cancelPendingReservation();
|
|
4157
|
+
};
|
|
4158
|
+
window.addEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
|
|
4159
|
+
return () => window.removeEventListener(BOOKING_FLOW_ABANDON_EVENT, handleAbandon);
|
|
4160
|
+
}, [cancelPendingReservation]);
|
|
4161
|
+
|
|
4162
|
+
useEffect(() => {
|
|
4163
|
+
const handlePageHide = () => {
|
|
4164
|
+
cancelPendingReservationBestEffort();
|
|
4165
|
+
};
|
|
4166
|
+
window.addEventListener('pagehide', handlePageHide);
|
|
4167
|
+
return () => window.removeEventListener('pagehide', handlePageHide);
|
|
4168
|
+
}, [cancelPendingReservationBestEffort]);
|
|
4169
|
+
|
|
4170
|
+
const handleCheckout = async () => {
|
|
4171
|
+
if (!selectedAvailability || totalQuantity === 0) {
|
|
4172
|
+
setError(t('booking.selectTimeAndTickets'));
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
if (missingRequiredReturnSelection) {
|
|
4176
|
+
setError('Removing return option in self-serve is not available. Please contact support.');
|
|
4177
|
+
return;
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
// Validate email (required)
|
|
4181
|
+
if (!email) {
|
|
4182
|
+
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
4183
|
+
return;
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
4187
|
+
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
4188
|
+
return;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
// Validate first name (required)
|
|
4192
|
+
if (!firstName?.trim()) {
|
|
4193
|
+
setError(t('booking.enterFirstName') || 'Please enter your first name');
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
|
|
4197
|
+
// Validate last name (required for manage booking lookup)
|
|
4198
|
+
if (!lastName?.trim()) {
|
|
4199
|
+
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
4204
|
+
if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
|
|
4205
|
+
setError(t('booking.selectPickupLocation'));
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
setLoading(true);
|
|
4210
|
+
setError('');
|
|
4211
|
+
paymentSubmitInFlightRef.current = false;
|
|
4212
|
+
|
|
4213
|
+
try {
|
|
4214
|
+
const bookingItems = Object.entries(quantities)
|
|
4215
|
+
.filter(([, count]) => count > 0)
|
|
4216
|
+
.map(([category, count]) => ({ category, count }));
|
|
4217
|
+
|
|
4218
|
+
// Get the productOptionId from the selected availability (we tagged it when fetching)
|
|
4219
|
+
const availabilityProductOptionId = selectedAvailability.productOptionId
|
|
4220
|
+
|| activeOptions[0]?.optionId;
|
|
4221
|
+
|
|
4222
|
+
if (!availabilityProductOptionId) {
|
|
4223
|
+
setError('No product option selected');
|
|
4224
|
+
setLoading(false);
|
|
4225
|
+
return;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
|
|
4229
|
+
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
4230
|
+
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
4231
|
+
product.productId,
|
|
4232
|
+
availabilityProductOptionId,
|
|
4233
|
+
),
|
|
4234
|
+
forcePartnerPortalChannel: partnerPortalBooking,
|
|
4235
|
+
forceDashboardSource: false,
|
|
4236
|
+
});
|
|
4237
|
+
|
|
4238
|
+
// Get the hotel name if a pickup location was selected
|
|
4239
|
+
const selectedPickupLocation = pickupLocationId
|
|
4240
|
+
? product.pickupLocations?.find(loc => loc.id === pickupLocationId)
|
|
4241
|
+
: null;
|
|
4242
|
+
let changeIntentIdForCheckout: string | undefined;
|
|
4243
|
+
let changeBookingReferenceForPaidFlow: string | undefined;
|
|
4244
|
+
|
|
4245
|
+
{
|
|
4246
|
+
const changeBookingReference = initialValues?.bookingReference?.trim();
|
|
4247
|
+
const changeLastName = lastName.trim();
|
|
4248
|
+
if (!changeBookingReference || !changeLastName) {
|
|
4249
|
+
throw new Error('Missing booking reference or last name for change quote');
|
|
4250
|
+
}
|
|
4251
|
+
const quote = await quoteChangeBooking({
|
|
4252
|
+
bookingReference: changeBookingReference,
|
|
4253
|
+
lastName: changeLastName,
|
|
4254
|
+
newProductId: availabilityProductOptionId,
|
|
4255
|
+
newDateTime: selectedAvailability.dateTime,
|
|
4256
|
+
newAvailabilityId: selectedAvailability.availabilityId || null,
|
|
4257
|
+
newPickupLocationId: pickupLocationId || null,
|
|
4258
|
+
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4259
|
+
newPassengerCounts: bookingItems,
|
|
4260
|
+
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
4261
|
+
clientProposedTotal:
|
|
4262
|
+
latestChangeQuote?.serverDisplay?.total ?? changeFlowNewBookingTotal,
|
|
4263
|
+
});
|
|
4264
|
+
const quoteSlice = sliceChangeQuoteForUi(
|
|
4265
|
+
quote,
|
|
4266
|
+
{
|
|
4267
|
+
total: changeFlowNewBookingTotal,
|
|
4268
|
+
subtotal: effectiveSubtotal,
|
|
4269
|
+
tax: effectiveTax,
|
|
4270
|
+
},
|
|
4271
|
+
currency
|
|
4272
|
+
);
|
|
4273
|
+
changeBookingReferenceForPaidFlow = changeBookingReference;
|
|
4274
|
+
changeIntentIdForCheckout = quoteSlice.changeIntentId ?? undefined;
|
|
4275
|
+
setLatestChangeQuote(
|
|
4276
|
+
mergeQuoteSliceWithServerPreview(quoteSlice, quote, {
|
|
4277
|
+
total: changeFlowNewBookingTotal,
|
|
4278
|
+
subtotal: effectiveSubtotal,
|
|
4279
|
+
tax: effectiveTax,
|
|
4280
|
+
}, currency),
|
|
4281
|
+
);
|
|
4282
|
+
if (!quoteSlice.canProceed) {
|
|
4283
|
+
throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
|
|
4284
|
+
}
|
|
4285
|
+
const serverNewTotalForGuard =
|
|
4286
|
+
quoteSlice.serverDisplay?.total ??
|
|
4287
|
+
quote.proposed?.total ??
|
|
4288
|
+
quote.newReceipt?.total ??
|
|
4289
|
+
changeFlowNewBookingTotal;
|
|
4290
|
+
const feChangeDue = changeFlowBalanceVsOriginal({
|
|
4291
|
+
newTotal: serverNewTotalForGuard,
|
|
4292
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4293
|
+
audience: 'customer',
|
|
4294
|
+
});
|
|
4295
|
+
const serverAmountDue =
|
|
4296
|
+
quote.amountDueCents != null
|
|
4297
|
+
? Math.max(0, quote.amountDueCents / 100)
|
|
4298
|
+
: Math.max(0, quote.priceDiff ?? 0);
|
|
4299
|
+
if (feChangeDue > 0.02 && serverAmountDue <= 0.009) {
|
|
4300
|
+
throw new Error(
|
|
4301
|
+
'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
|
|
4302
|
+
);
|
|
4303
|
+
}
|
|
4304
|
+
// No-payment change: FE shows nothing owed — still require server agreement so we never confirm free when a charge is due.
|
|
4305
|
+
if (serverAmountDue <= 0.009) {
|
|
4306
|
+
if (feChangeDue > 0.02) {
|
|
4307
|
+
throw new Error(
|
|
4308
|
+
'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
|
|
4309
|
+
);
|
|
4310
|
+
}
|
|
4311
|
+
const p = quote.proposed?.total ?? quote.newReceipt?.total;
|
|
4312
|
+
const o = quote.original?.total ?? quote.originalReceipt?.total;
|
|
4313
|
+
if (p != null && o != null && p - o > 0.01) {
|
|
4314
|
+
throw new Error(
|
|
4315
|
+
'This change requires payment, but the price could not be confirmed. Please refresh and try again.'
|
|
4316
|
+
);
|
|
4317
|
+
}
|
|
4318
|
+
if (!quote.changeIntentId) {
|
|
4319
|
+
throw new Error('Missing change intent for booking change confirmation.');
|
|
4320
|
+
}
|
|
4321
|
+
const freeConfirm = await confirmFreeChangeBooking(quote.changeIntentId);
|
|
4322
|
+
if (freeConfirm.status && freeConfirm.status !== 'APPLIED') {
|
|
4323
|
+
throw new Error('We could not apply this booking change yet. Please try again.');
|
|
4324
|
+
}
|
|
4325
|
+
onSuccess?.({ reservationReference: changeBookingReference });
|
|
4326
|
+
setLoading(false);
|
|
4327
|
+
return;
|
|
4328
|
+
}
|
|
4329
|
+
if (!changeIntentIdForCheckout) {
|
|
4330
|
+
throw new Error('Missing change intent for payment.');
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
pendingReservationRef.current = null;
|
|
4335
|
+
|
|
4336
|
+
// Note: Do NOT call onSuccess here for paid bookings — we're about to show the Stripe
|
|
4337
|
+
// CheckoutModal. onSuccess (e.g. closing the parent dialog) should only run when we're
|
|
4338
|
+
// actually done (free booking redirect, admin confirm-without-payment). Calling it here
|
|
4339
|
+
// would close the dialog before the payment modal opens.
|
|
4340
|
+
|
|
4341
|
+
// Update stored booking data (no holds reservation — change flow keys off booking reference elsewhere).
|
|
4342
|
+
try {
|
|
4343
|
+
const storedBooking = sessionStorage.getItem('pendingBooking');
|
|
4344
|
+
if (storedBooking) {
|
|
4345
|
+
const booking = JSON.parse(storedBooking);
|
|
4346
|
+
booking.totalPrice = totalPrice;
|
|
4347
|
+
booking.currency = currency || selectedAvailability.currency || 'CAD';
|
|
4348
|
+
sessionStorage.setItem('pendingBooking', JSON.stringify(booking));
|
|
4349
|
+
}
|
|
4350
|
+
} catch (e) {
|
|
4351
|
+
console.warn('Failed to update booking data', e);
|
|
4352
|
+
}
|
|
4353
|
+
|
|
4354
|
+
// Create Payment Intent for embedded checkout modal (order summary with strikethrough + green, Payment Element)
|
|
4355
|
+
const datePart = selectedAvailability.dateTime.split('T')[0];
|
|
4356
|
+
const timePart = selectedAvailability.dateTime.split('T')[1]?.substring(0, 5) || '00:00';
|
|
4357
|
+
|
|
4358
|
+
// Itinerary for storage: when pickup unknown, pickup step is always a range (e.g. "9 AM - 10:00 AM") so /manage and email show it; drop-off stays TBD
|
|
4359
|
+
const itineraryDisplay = computeItineraryDisplayForStorage() ?? computeItineraryDisplay();
|
|
4360
|
+
|
|
4361
|
+
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
4362
|
+
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
4363
|
+
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
4364
|
+
const amountDueForCheckout = changeFlowBalanceVsOriginal({
|
|
4365
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4366
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4367
|
+
audience: 'customer',
|
|
4368
|
+
});
|
|
4369
|
+
const lines = [
|
|
4370
|
+
...ticketLineItemsForChangeFlowDisplay.map((line) => ({
|
|
4371
|
+
label: line.category,
|
|
4372
|
+
amount: line.itemTotal,
|
|
4373
|
+
type: 'TICKET' as const,
|
|
4374
|
+
quantity: line.qty,
|
|
4375
|
+
})),
|
|
4376
|
+
...(checkoutReturnLineAmount !== 0
|
|
4377
|
+
? [
|
|
4378
|
+
{
|
|
4379
|
+
label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
4380
|
+
amount: checkoutReturnLineAmount,
|
|
4381
|
+
type: 'RETURN_OPTION' as const,
|
|
4382
|
+
quantity: totalQuantity,
|
|
4383
|
+
},
|
|
4384
|
+
]
|
|
4385
|
+
: []),
|
|
4386
|
+
...(cancellationPolicyFee > 0
|
|
4387
|
+
? [
|
|
4388
|
+
{
|
|
4389
|
+
label: effectiveCancellationPolicyLabel,
|
|
4390
|
+
amount: cancellationPolicyFee,
|
|
4391
|
+
type: 'CANCELLATION_UPGRADE' as const,
|
|
4392
|
+
},
|
|
4393
|
+
]
|
|
4394
|
+
: []),
|
|
4395
|
+
...addOnSelections
|
|
4396
|
+
.map((sel) => {
|
|
4397
|
+
const addOn = addOns.find((a) => a.addOnId === sel.addOnId);
|
|
4398
|
+
if (!addOn) return null;
|
|
4399
|
+
const base = addOn.price ?? 0;
|
|
4400
|
+
const hasVariant = (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') && sel.variantId;
|
|
4401
|
+
const adj = hasVariant ? (addOn.variants?.find((v) => v.id === sel.variantId)?.priceAdjustment ?? 0) : 0;
|
|
4402
|
+
const qty = sel.quantity ?? 1;
|
|
4403
|
+
const amt = (base + adj) * qty;
|
|
4404
|
+
const variantLabel = hasVariant ? addOn.variants?.find((v) => v.id === sel.variantId)?.label : null;
|
|
4405
|
+
return {
|
|
4406
|
+
label: variantLabel ? `${addOn.name} (${variantLabel})${qty > 1 ? ` × ${qty}` : ''}` : addOn.name,
|
|
4407
|
+
amount: amt,
|
|
4408
|
+
type: 'FEE' as const,
|
|
4409
|
+
quantity: qty,
|
|
4410
|
+
};
|
|
4411
|
+
})
|
|
4412
|
+
.filter((x): x is NonNullable<typeof x> => x != null),
|
|
4413
|
+
...feeLineItems.map((fee) => ({
|
|
4414
|
+
label: `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
4415
|
+
amount: fee.totalAmount,
|
|
4416
|
+
type: 'FEE' as const,
|
|
4417
|
+
quantity: totalQuantity,
|
|
4418
|
+
})),
|
|
4419
|
+
...(!isTaxIncludedInPrice && taxForBreakdown > 0
|
|
4420
|
+
? [
|
|
4421
|
+
{
|
|
4422
|
+
label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
|
|
4423
|
+
amount: taxForBreakdown,
|
|
4424
|
+
type: 'TAX' as const,
|
|
4425
|
+
},
|
|
4426
|
+
]
|
|
4427
|
+
: []),
|
|
4428
|
+
...(effectivePromoDiscountAmount > 0
|
|
4429
|
+
? [
|
|
4430
|
+
{
|
|
4431
|
+
label: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || (t('booking.discount') || 'Discount')),
|
|
4432
|
+
amount: -effectivePromoDiscountAmount,
|
|
4433
|
+
type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
|
|
4434
|
+
},
|
|
4435
|
+
]
|
|
4436
|
+
: []),
|
|
4437
|
+
];
|
|
4438
|
+
const checkoutBreakdown = buildCheckoutBreakdown({
|
|
4439
|
+
lines,
|
|
4440
|
+
totalAmount: amountDueForCheckout,
|
|
4441
|
+
currency,
|
|
4442
|
+
roundingLabel: t('booking.rounding') || 'Rounding',
|
|
4443
|
+
});
|
|
4444
|
+
|
|
4445
|
+
const paymentIntent = await createChangeBookingPaymentIntent(
|
|
4446
|
+
(() => {
|
|
4447
|
+
const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
|
|
4448
|
+
if (!id) {
|
|
4449
|
+
throw new Error('Missing change intent for payment.');
|
|
4450
|
+
}
|
|
4451
|
+
return id;
|
|
4452
|
+
})()
|
|
4453
|
+
);
|
|
4454
|
+
|
|
4455
|
+
|
|
4456
|
+
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4457
|
+
const rate = pricing.find((r) => r.category === line.category);
|
|
4458
|
+
const breakdown = getPriceBreakdown(
|
|
4459
|
+
line.category,
|
|
4460
|
+
rate?.priceCAD ?? 0,
|
|
4461
|
+
rate?.baseInDisplayCurrency,
|
|
4462
|
+
rate?.appliedAdjustments ?? []
|
|
4463
|
+
);
|
|
4464
|
+
return { line, breakdown };
|
|
4465
|
+
});
|
|
4466
|
+
|
|
4467
|
+
setCheckoutClientSecret(paymentIntent.clientSecret ?? '');
|
|
4468
|
+
setCheckoutModalData({
|
|
4469
|
+
reservationReference: changeBookingReferenceForPaidFlow ?? '',
|
|
4470
|
+
reservationExpiration: undefined,
|
|
4471
|
+
customerLastName: lastName.trim(),
|
|
4472
|
+
bookingDate: datePart,
|
|
4473
|
+
// Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
|
|
4474
|
+
// /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
|
|
4475
|
+
successUrlOverride:
|
|
4476
|
+
true && changeBookingReferenceForPaidFlow
|
|
4477
|
+
? (() => {
|
|
4478
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
4479
|
+
const ref = encodeURIComponent(
|
|
4480
|
+
formatBookingRefForDisplay(changeBookingReferenceForPaidFlow) || changeBookingReferenceForPaidFlow,
|
|
4481
|
+
);
|
|
4482
|
+
const ln = encodeURIComponent(lastName.trim());
|
|
4483
|
+
const fromQ = `${MANAGE_BOOKING_QUERY_FROM}=${encodeURIComponent(MANAGE_BOOKING_FROM_CHANGE_PAYMENT)}`;
|
|
4484
|
+
const intentQ = changeIntentIdForCheckout
|
|
4485
|
+
? `&changeIntentId=${encodeURIComponent(changeIntentIdForCheckout)}`
|
|
4486
|
+
: '';
|
|
4487
|
+
return `${origin}/manage-booking?ref=${ref}&lastName=${ln}&${fromQ}${intentQ}`;
|
|
4488
|
+
})()
|
|
4489
|
+
: undefined,
|
|
4490
|
+
ticketLines: ticketLinesForModal,
|
|
4491
|
+
feeLineItems: feeLineItemsWithAddOns,
|
|
4492
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4493
|
+
cancellationPolicyFee,
|
|
4494
|
+
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4495
|
+
subtotal: effectiveSubtotal,
|
|
4496
|
+
tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
|
|
4497
|
+
total: amountDueForCheckout,
|
|
4498
|
+
totalQuantity,
|
|
4499
|
+
isTaxIncludedInPrice,
|
|
4500
|
+
taxRate: pricingConfig?.taxRate ?? 0,
|
|
4501
|
+
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
4502
|
+
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
4503
|
+
changeTotals:
|
|
4504
|
+
true && originalReceipt
|
|
4505
|
+
? {
|
|
4506
|
+
previousTotal: originalReceipt.total,
|
|
4507
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
4508
|
+
differenceTotal: amountDueForCheckout,
|
|
4509
|
+
}
|
|
4510
|
+
: undefined,
|
|
4511
|
+
});
|
|
4512
|
+
setShowCheckoutModal(true);
|
|
4513
|
+
setLoading(false);
|
|
4514
|
+
} catch (err) {
|
|
4515
|
+
if (isInsufficientCapacityReserveError(err)) {
|
|
4516
|
+
try {
|
|
4517
|
+
const merged = await reloadAvailabilitiesAfterReserveConflict();
|
|
4518
|
+
const outbound = findMergedAvailabilityForSelection(merged, selectedAvailability);
|
|
4519
|
+
const outboundVacancies = outbound?.vacancies ?? null;
|
|
4520
|
+
const returnVacancies = findMergedReturnVacancies(outbound, selectedReturnOption);
|
|
4521
|
+
setError(
|
|
4522
|
+
describeStandardTourCapacityConflictMessage({
|
|
4523
|
+
partySize: totalQuantity,
|
|
4524
|
+
outboundVacancies,
|
|
4525
|
+
returnVacancies,
|
|
4526
|
+
hasReturnSelection: !!selectedReturnOption,
|
|
4527
|
+
})
|
|
4528
|
+
);
|
|
4529
|
+
reportReserveCapacityConflictClientContext({
|
|
4530
|
+
flow: 'standard_tour',
|
|
4531
|
+
productId: product.productId,
|
|
4532
|
+
selectedDate: selectedDate || null,
|
|
4533
|
+
outboundDateTime: selectedAvailability?.dateTime ?? null,
|
|
4534
|
+
outboundVacanciesAfterRefresh: outboundVacancies,
|
|
4535
|
+
returnVacanciesAfterRefresh: returnVacancies,
|
|
4536
|
+
partySizeOrPassengers: totalQuantity,
|
|
4537
|
+
hasReturnSelection: !!selectedReturnOption,
|
|
4538
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4539
|
+
reloadAvailabilitiesSucceeded: true,
|
|
4540
|
+
});
|
|
4541
|
+
} catch (reloadErr) {
|
|
4542
|
+
setError(
|
|
4543
|
+
describeStandardTourCapacityConflictMessage({
|
|
4544
|
+
partySize: totalQuantity,
|
|
4545
|
+
outboundVacancies: null,
|
|
4546
|
+
returnVacancies: null,
|
|
4547
|
+
hasReturnSelection: !!selectedReturnOption,
|
|
4548
|
+
})
|
|
4549
|
+
);
|
|
4550
|
+
reportReserveCapacityConflictClientContext({
|
|
4551
|
+
flow: 'standard_tour',
|
|
4552
|
+
productId: product.productId,
|
|
4553
|
+
selectedDate: selectedDate || null,
|
|
4554
|
+
outboundDateTime: selectedAvailability?.dateTime ?? null,
|
|
4555
|
+
outboundVacanciesAfterRefresh: null,
|
|
4556
|
+
returnVacanciesAfterRefresh: null,
|
|
4557
|
+
partySizeOrPassengers: totalQuantity,
|
|
4558
|
+
hasReturnSelection: !!selectedReturnOption,
|
|
4559
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4560
|
+
reloadAvailabilitiesSucceeded: false,
|
|
4561
|
+
reloadErrorMessage:
|
|
4562
|
+
reloadErr instanceof Error ? reloadErr.message : String(reloadErr),
|
|
4563
|
+
});
|
|
4564
|
+
}
|
|
4565
|
+
setLoading(false);
|
|
4566
|
+
return;
|
|
4567
|
+
}
|
|
4568
|
+
setError(err instanceof Error ? err.message : 'Something went wrong');
|
|
4569
|
+
setLoading(false);
|
|
4570
|
+
}
|
|
4571
|
+
};
|
|
4572
|
+
|
|
4573
|
+
|
|
4574
|
+
|
|
4575
|
+
if (activeOptions.length === 0) {
|
|
4576
|
+
return (
|
|
4577
|
+
<div className="flex items-center justify-center py-16">
|
|
4578
|
+
<div className="text-red-600">{t('booking.noActiveOption') || 'No active product options available'}</div>
|
|
4579
|
+
</div>
|
|
4580
|
+
);
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
return (
|
|
4584
|
+
<div className="booking-flow-root space-y-8">
|
|
4585
|
+
{checkoutModalData && (
|
|
4586
|
+
<CheckoutModal
|
|
4587
|
+
open={showCheckoutModal}
|
|
4588
|
+
onClose={cancelPendingReservation}
|
|
4589
|
+
onPaymentSubmitStart={() => {
|
|
4590
|
+
paymentSubmitInFlightRef.current = true;
|
|
4591
|
+
}}
|
|
4592
|
+
onPaymentSubmitError={() => {
|
|
4593
|
+
paymentSubmitInFlightRef.current = false;
|
|
4594
|
+
}}
|
|
4595
|
+
clientSecret={checkoutClientSecret}
|
|
4596
|
+
reservationReference={checkoutModalData.reservationReference}
|
|
4597
|
+
reservationExpiration={checkoutModalData.reservationExpiration}
|
|
4598
|
+
customerLastName={checkoutModalData.customerLastName}
|
|
4599
|
+
successUrlOverride={
|
|
4600
|
+
checkoutModalData.successUrlOverride ??
|
|
4601
|
+
(getSuccessUrl
|
|
4602
|
+
? getSuccessUrl({
|
|
4603
|
+
reservationRef: checkoutModalData.reservationReference,
|
|
4604
|
+
lastName: checkoutModalData.customerLastName ?? '',
|
|
4605
|
+
focusDate: checkoutModalData.bookingDate,
|
|
4606
|
+
})
|
|
4607
|
+
: undefined)
|
|
4608
|
+
}
|
|
4609
|
+
ticketLines={checkoutModalData.ticketLines}
|
|
4610
|
+
feeLineItems={checkoutModalData.feeLineItems}
|
|
4611
|
+
returnPriceAdjustment={checkoutModalData.returnPriceAdjustment}
|
|
4612
|
+
cancellationPolicyFee={checkoutModalData.cancellationPolicyFee}
|
|
4613
|
+
cancellationPolicyLabel={checkoutModalData.cancellationPolicyLabel}
|
|
4614
|
+
subtotal={checkoutModalData.subtotal}
|
|
4615
|
+
tax={checkoutModalData.tax}
|
|
4616
|
+
total={checkoutModalData.total}
|
|
4617
|
+
promoDiscountAmount={checkoutModalData.promoDiscountAmount ?? 0}
|
|
4618
|
+
discountLabel={checkoutModalData.discountLabel}
|
|
4619
|
+
totalQuantity={checkoutModalData.totalQuantity}
|
|
4620
|
+
isTaxIncludedInPrice={checkoutModalData.isTaxIncludedInPrice}
|
|
4621
|
+
taxRate={checkoutModalData.taxRate}
|
|
4622
|
+
changeTotals={checkoutModalData.changeTotals}
|
|
4623
|
+
currency={currency}
|
|
4624
|
+
locale={locale}
|
|
4625
|
+
t={t}
|
|
4626
|
+
/>
|
|
4627
|
+
)}
|
|
4628
|
+
{isPartialLaunch ? null : (
|
|
4629
|
+
<div className="booking-calendar-section">
|
|
4630
|
+
{loadingAvailabilities && availabilities.length === 0 ? (
|
|
4631
|
+
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
4632
|
+
<div className="booking-loading-spinner" aria-hidden />
|
|
4633
|
+
<div className="text-stone-600">{t('booking.loadingTimes')}</div>
|
|
4634
|
+
</div>
|
|
4635
|
+
) : availabilities.length === 0 ? (
|
|
4636
|
+
<div className="text-center py-8 text-stone-500">
|
|
4637
|
+
{t('booking.noAvailability')}
|
|
4638
|
+
</div>
|
|
4639
|
+
) : (
|
|
4640
|
+
<>
|
|
4641
|
+
{/* Date Selection */}
|
|
4642
|
+
<div>
|
|
4643
|
+
<div className="relative">
|
|
4644
|
+
{loadingAvailabilities && (
|
|
4645
|
+
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg">
|
|
4646
|
+
<div className="flex flex-col items-center gap-3">
|
|
4647
|
+
<div className="booking-loading-spinner" aria-hidden />
|
|
4648
|
+
<div className="text-stone-600">{t('booking.loadingTimes')}</div>
|
|
4649
|
+
</div>
|
|
4650
|
+
</div>
|
|
4651
|
+
)}
|
|
4652
|
+
<Calendar
|
|
4653
|
+
availabilitiesByDate={availabilitiesByDate}
|
|
4654
|
+
selectedDate={selectedDate}
|
|
4655
|
+
syncVisibleWeekToSelectedDate={true}
|
|
4656
|
+
selectableSoldOutDate={changeFlowOriginalDate}
|
|
4657
|
+
partySizeRequiredForCalendarSelection={
|
|
4658
|
+
true && !false && changeFlowInitialTicketCount > 0
|
|
4659
|
+
? changeFlowInitialTicketCount
|
|
4660
|
+
: undefined
|
|
4661
|
+
}
|
|
4662
|
+
getEffectiveVacancies={
|
|
4663
|
+
true && !false && changeFlowInitialTicketCount > 0
|
|
4664
|
+
? getCalendarEffectiveOutboundVacancies
|
|
4665
|
+
: undefined
|
|
4666
|
+
}
|
|
4667
|
+
isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
|
|
4668
|
+
onDateSelect={(date) => {
|
|
4669
|
+
setSelectedDate(date);
|
|
4670
|
+
handleDateSelect(date);
|
|
4671
|
+
if (suppressCalendarDateScroll) return;
|
|
4672
|
+
// Scroll so calendar is almost at top and user sees the rest of the booking flow.
|
|
4673
|
+
// Dialog: scroll inside contentRef. Full-page: fall back to window scroll.
|
|
4674
|
+
setTimeout(() => {
|
|
4675
|
+
const container = contentRef?.current;
|
|
4676
|
+
if (!useWindowScroll && container && container.scrollHeight > container.clientHeight + 16) {
|
|
4677
|
+
container.scrollBy({ top: 400, behavior: 'smooth' });
|
|
4678
|
+
} else if (typeof window !== 'undefined') {
|
|
4679
|
+
window.scrollBy({ top: 400, behavior: 'smooth' });
|
|
4680
|
+
}
|
|
4681
|
+
}, 100);
|
|
4682
|
+
}}
|
|
4683
|
+
timezone={companyTimezone}
|
|
4684
|
+
earliestDate={earliestAvailabilityDate}
|
|
4685
|
+
onVisibleRangeChange={handleVisibleRangeChange}
|
|
4686
|
+
currency={currency}
|
|
4687
|
+
showCapacity={false}
|
|
4688
|
+
extraDiscountPercent={calendarDiscountPercent}
|
|
4689
|
+
capDiscountBadgesToBookingDate={changeFlowOriginalDate}
|
|
4690
|
+
/>
|
|
4691
|
+
</div>
|
|
4692
|
+
</div>
|
|
4693
|
+
|
|
4694
|
+
{/* Form sections - equal spacing between each */}
|
|
4695
|
+
<div className="mt-6 space-y-6">
|
|
4696
|
+
{/* Your itinerary box - shown after date selection, before pickup/return/tickets/pickup location */}
|
|
4697
|
+
{selectedDate && !hideItineraryBox && (() => {
|
|
4698
|
+
const hasItineraryAny = activeOptions.some(o => o.itinerary?.length) && (product.destinations?.length ?? 0) > 0;
|
|
4699
|
+
if (!hasItineraryAny) return null;
|
|
4700
|
+
const formattedDate = selectedDate ? format(parseISO(selectedDate), 'MMM d') : '';
|
|
4701
|
+
if (!selectedAvailability) {
|
|
4702
|
+
return <ItineraryPlaceholder formattedDate={formattedDate} t={t} />;
|
|
4703
|
+
}
|
|
4704
|
+
const itineraryItems = computeItineraryDisplay();
|
|
4705
|
+
if (!itineraryItems || itineraryItems.length === 0) return null;
|
|
4706
|
+
const isBookingComplete = Boolean(selectedAvailability &&
|
|
4707
|
+
selectedReturnOption &&
|
|
4708
|
+
(pickupLocationId || pickupLocationSkipped || !product.pickupLocations || product.pickupLocations.length === 0) &&
|
|
4709
|
+
Object.values(quantities).some(qty => qty > 0) &&
|
|
4710
|
+
email.trim() !== '' &&
|
|
4711
|
+
firstName.trim() !== '' &&
|
|
4712
|
+
lastName.trim() !== '');
|
|
4713
|
+
return (
|
|
4714
|
+
<ItineraryBox
|
|
4715
|
+
selectedDate={selectedDate}
|
|
4716
|
+
formattedDate={formattedDate}
|
|
4717
|
+
itineraryItems={itineraryItems}
|
|
4718
|
+
isBookingComplete={isBookingComplete}
|
|
4719
|
+
isItinerarySticky={isItinerarySticky}
|
|
4720
|
+
stickyTopPx={flowUi?.itineraryStickyTopOffsetPx}
|
|
4721
|
+
isMobile={isMobile}
|
|
4722
|
+
useWindowScroll={useWindowScroll}
|
|
4723
|
+
showTooltip={showTooltip}
|
|
4724
|
+
selectedPickupLocation={selectedPickupLocation}
|
|
4725
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
4726
|
+
pickupLocationsCount={product.pickupLocations?.length ?? 0}
|
|
4727
|
+
itineraryRef={itineraryRef}
|
|
4728
|
+
t={t}
|
|
4729
|
+
onTooltipToggle={() => setShowTooltip(!showTooltip)}
|
|
4730
|
+
onTooltipShow={setShowTooltip}
|
|
4731
|
+
/>
|
|
4732
|
+
);
|
|
4733
|
+
})()}
|
|
4734
|
+
|
|
4735
|
+
{/* Select pickup time */}
|
|
4736
|
+
{selectedDate && (
|
|
4737
|
+
<PickupTimeSelector
|
|
4738
|
+
pickupTimes={pickupTimes}
|
|
4739
|
+
selectedDateTime={selectedAvailability?.dateTime ?? null}
|
|
4740
|
+
selectedTicketCount={totalQuantity}
|
|
4741
|
+
optionsMap={optionsMap}
|
|
4742
|
+
hasAnyMostPopular={hasAnyMostPopular}
|
|
4743
|
+
isAdmin={false}
|
|
4744
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
4745
|
+
t={t}
|
|
4746
|
+
onTimeSelect={handleTimeSelect}
|
|
4747
|
+
/>
|
|
4748
|
+
)}
|
|
4749
|
+
|
|
4750
|
+
{/* Select return time */}
|
|
4751
|
+
{selectedAvailability && selectedAvailability.returnOptions && selectedAvailability.returnOptions.length > 0 && (
|
|
4752
|
+
<ReturnTimeSelector
|
|
4753
|
+
returnOptions={returnOptionsWithFloor}
|
|
4754
|
+
selectedReturnOption={selectedReturnOptionWithFloor}
|
|
4755
|
+
selectedTicketCount={totalQuantity}
|
|
4756
|
+
companyTimezone={companyTimezone}
|
|
4757
|
+
currency={currency}
|
|
4758
|
+
locale={locale}
|
|
4759
|
+
isAdmin={false}
|
|
4760
|
+
t={t}
|
|
4761
|
+
onReturnSelect={(option) => {
|
|
4762
|
+
const raw = selectedAvailability.returnOptions?.find(
|
|
4763
|
+
(opt) => opt.returnAvailabilityId === option.returnAvailabilityId
|
|
4764
|
+
);
|
|
4765
|
+
setSelectedReturnOption(raw ?? option);
|
|
4766
|
+
}}
|
|
4767
|
+
getStaySummary={calculateStaySummary}
|
|
4768
|
+
suppressPerPersonPrices={false}
|
|
4769
|
+
/>
|
|
4770
|
+
)}
|
|
4771
|
+
|
|
4772
|
+
{/* Ticket Selection */}
|
|
4773
|
+
{selectedAvailability && (
|
|
4774
|
+
<TicketSelector
|
|
4775
|
+
pricing={pricingForTicketSelector}
|
|
4776
|
+
quantities={quantities}
|
|
4777
|
+
totalQuantity={totalQuantity}
|
|
4778
|
+
selectedVacancies={effectivePartySizeCap}
|
|
4779
|
+
companyTimezone={companyTimezone}
|
|
4780
|
+
pickupDateTime={selectedAvailability.dateTime}
|
|
4781
|
+
pickupVacancies={effectiveSelectedPickupVacancies}
|
|
4782
|
+
returnDateTime={selectedReturnOption?.dateTime ?? null}
|
|
4783
|
+
returnVacancies={effectiveSelectedReturnVacancies}
|
|
4784
|
+
resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
|
|
4785
|
+
currency={currency}
|
|
4786
|
+
locale={locale}
|
|
4787
|
+
isAdmin={false}
|
|
4788
|
+
isSimplifiedPricingView={isSimplifiedPricingView}
|
|
4789
|
+
t={t}
|
|
4790
|
+
onQuantityChange={handleQuantityChange}
|
|
4791
|
+
minimumQuantities={changeBookingMinimumQuantities}
|
|
4792
|
+
ticketUnitFloorByCategory={
|
|
4793
|
+
changeFlowApplyReceiptPaidFloors
|
|
4794
|
+
? changeFlowTicketBookedUnitPriceByCategory
|
|
4795
|
+
: undefined
|
|
4796
|
+
}
|
|
4797
|
+
suppressUnitPrices={false}
|
|
4798
|
+
/>
|
|
4799
|
+
)}
|
|
4800
|
+
|
|
4801
|
+
{/* Add-ons — optional extras for the selected product option */}
|
|
4802
|
+
{selectedAvailability && totalQuantity > 0 && addOns.length > 0 && (
|
|
4803
|
+
<AddOnsSection
|
|
4804
|
+
addOns={addOns}
|
|
4805
|
+
addOnSelections={addOnSelections}
|
|
4806
|
+
currency={currency}
|
|
4807
|
+
locale={locale}
|
|
4808
|
+
onSelectionsChange={updateAddOnSelections}
|
|
4809
|
+
minimumTotalByAddOnId={initialAddOnMinTotalByAddOnId}
|
|
4810
|
+
suppressPrices={false}
|
|
4811
|
+
/>
|
|
4812
|
+
)}
|
|
4813
|
+
|
|
4814
|
+
{/* Total and Checkout — shared PriceSummary component */}
|
|
4815
|
+
{selectedAvailability && (
|
|
4816
|
+
<>
|
|
4817
|
+
<CheckoutForm
|
|
4818
|
+
priceSummaryLines={checkoutPriceSummaryLinesForCheckout}
|
|
4819
|
+
replacePriceSummary={selfServeCheckoutPlaceholder}
|
|
4820
|
+
totalPrice={changeFlowAmountDue}
|
|
4821
|
+
totalSummaryLabel={
|
|
4822
|
+
t('booking.totalOwedForBookingChange') &&
|
|
4823
|
+
t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
|
|
4824
|
+
? t('booking.totalOwedForBookingChange')
|
|
4825
|
+
: 'Total owed for booking difference'
|
|
4826
|
+
}
|
|
4827
|
+
subtotal={displayChangeFlowSubtotal}
|
|
4828
|
+
taxAmount={
|
|
4829
|
+
!isTaxIncludedInPrice &&
|
|
4830
|
+
displayChangeFlowTax > 0 &&
|
|
4831
|
+
!priceSummaryLinesIncludeTaxRow
|
|
4832
|
+
? displayChangeFlowTax
|
|
4833
|
+
: 0
|
|
4834
|
+
}
|
|
4835
|
+
taxRate={pricingConfig?.taxRate}
|
|
4836
|
+
currency={currency}
|
|
4837
|
+
locale={locale}
|
|
4838
|
+
t={t}
|
|
4839
|
+
extraBetweenTaxAndTotal={undefined}
|
|
4840
|
+
extraBeforeSubtotal={undefined}
|
|
4841
|
+
firstName={firstName}
|
|
4842
|
+
lastName={lastName}
|
|
4843
|
+
email={email}
|
|
4844
|
+
onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
|
|
4845
|
+
onLastNameChange={(v) => { setLastName(v); setError(''); }}
|
|
4846
|
+
onEmailChange={(v) => { setEmail(v); setError(''); }}
|
|
4847
|
+
readOnlyContactFields={false}
|
|
4848
|
+
pickupLocations={
|
|
4849
|
+
selectedDate && product.pickupLocations && product.pickupLocations.length > 0
|
|
4850
|
+
? product.pickupLocations
|
|
4851
|
+
: undefined
|
|
4852
|
+
}
|
|
4853
|
+
destinations={product.destinations}
|
|
4854
|
+
pickupLocationId={pickupLocationId}
|
|
4855
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
4856
|
+
selectedPickupLocation={selectedPickupLocation}
|
|
4857
|
+
highlightedPickupLocationIds={highlightedPickupLocationIds}
|
|
4858
|
+
onLocationSelect={(locationId) => {
|
|
4859
|
+
setPickupLocationId(locationId);
|
|
4860
|
+
setError('');
|
|
4861
|
+
if (locationId === null && pickupLocationSkipped) {
|
|
4862
|
+
setPickupLocationSkipped(false);
|
|
4863
|
+
} else if (locationId !== null) {
|
|
4864
|
+
setPickupLocationSkipped(false);
|
|
4865
|
+
}
|
|
4866
|
+
}}
|
|
4867
|
+
onSkip={() => {
|
|
4868
|
+
setPickupLocationSkipped(true);
|
|
4869
|
+
setPickupLocationId(null);
|
|
4870
|
+
setError('');
|
|
4871
|
+
}}
|
|
4872
|
+
onChangePickup={() => {
|
|
4873
|
+
setPickupLocationId(null);
|
|
4874
|
+
setPickupLocationSkipped(false);
|
|
4875
|
+
}}
|
|
4876
|
+
termsAccepted={termsAccepted}
|
|
4877
|
+
onTermsChange={(checked) => {
|
|
4878
|
+
setTermsAccepted(checked);
|
|
4879
|
+
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
4880
|
+
}}
|
|
4881
|
+
isAdmin={false}
|
|
4882
|
+
showCommunicationAdminSection={false}
|
|
4883
|
+
skipConfirmationCommunications={false}
|
|
4884
|
+
disableAutoCommunications={false}
|
|
4885
|
+
onSkipConfirmationChange={() => {}}
|
|
4886
|
+
onDisableCommunicationsChange={() => {}}
|
|
4887
|
+
error={checkoutFormError}
|
|
4888
|
+
loading={loading}
|
|
4889
|
+
totalQuantity={totalQuantity}
|
|
4890
|
+
onCheckout={handleCheckout}
|
|
4891
|
+
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
4892
|
+
hideSubmitButton={
|
|
4893
|
+
showCheckoutModal || !hasEffectiveChangeSelection || isChangeQuoteBlocked
|
|
4894
|
+
}
|
|
4895
|
+
submitDisabled={changeFlowSubmitDisabled}
|
|
4896
|
+
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
4897
|
+
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
4898
|
+
attributionConfirmed={partnerAttributionConfirmed}
|
|
4899
|
+
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
4900
|
+
lineAmountInputs={undefined}
|
|
4901
|
+
onLineAmountInputChange={undefined}
|
|
4902
|
+
onLineAmountInputBlur={undefined}
|
|
4903
|
+
onLineAmountReset={undefined}
|
|
4904
|
+
/>
|
|
4905
|
+
</>
|
|
4906
|
+
)}
|
|
4907
|
+
</div>
|
|
4908
|
+
</>
|
|
4909
|
+
)}
|
|
4910
|
+
</div>
|
|
4911
|
+
)}
|
|
4912
|
+
</div>
|
|
4913
|
+
);
|
|
4914
|
+
}
|
|
4915
|
+
|