@ticketboothapp/booking 1.2.59 → 1.2.60
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.
|
@@ -47,9 +47,20 @@ import { useTranslations, useLocale } from '../../lib/booking/i18n';
|
|
|
47
47
|
import { type Currency } from './CurrencySwitcher';
|
|
48
48
|
import { formatBookingRefForDisplay } from '../../lib/booking-ref';
|
|
49
49
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
effectiveProductOptionIdForChangeFlow,
|
|
51
|
+
isParentProductId,
|
|
52
|
+
normalizeProductOptionIdForChangeFlow,
|
|
53
|
+
} from '../../lib/booking/product-option-id';
|
|
54
|
+
import { formatCurrencyAmount } from '../../lib/currency';
|
|
55
|
+
import {
|
|
56
|
+
resolveChangeFlowNewBookingTotal,
|
|
57
|
+
changeFlowBalanceVsOriginal,
|
|
58
|
+
resolveChangeFlowDisplayedAmounts,
|
|
59
|
+
normalizeNearZeroOwed,
|
|
60
|
+
changeFlowTicketLineTotalWithReceiptFloor,
|
|
61
|
+
changeFlowFeeLineTotalWithReceiptFloor,
|
|
62
|
+
changeFlowReturnPerPersonWithReceiptFloor,
|
|
63
|
+
} from '../../lib/booking/change-flow-pricing';
|
|
53
64
|
import { buildCheckoutBreakdown } from '../../lib/booking/checkout-breakdown';
|
|
54
65
|
import type { PricingConfig, PrecomputedPricesByCategory, ItineraryDisplayStep } from '../../lib/booking-api';
|
|
55
66
|
import { ItineraryStepType as StepType } from '../../lib/booking-api';
|
|
@@ -79,16 +90,6 @@ import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
|
79
90
|
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
80
91
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
81
92
|
|
|
82
|
-
/**
|
|
83
|
-
* Change-booking diagnostics in the console on local machines only.
|
|
84
|
-
* Uses hostname (not NODE_ENV): bundled packages often inline production, so `development` was never true in the browser.
|
|
85
|
-
*/
|
|
86
|
-
function isLocalhostChangeBookingDebug(): boolean {
|
|
87
|
-
if (typeof window === 'undefined') return false;
|
|
88
|
-
const h = window.location.hostname;
|
|
89
|
-
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
93
|
/** Live selection snapshot for change-booking compare UI (parent dialog). */
|
|
93
94
|
export interface ChangeFlowSelectionPreview {
|
|
94
95
|
tourName: string;
|
|
@@ -153,140 +154,6 @@ function normalizeLineLabelForCompare(label: string): string {
|
|
|
153
154
|
.trim();
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
/** Mirrors BookingFlow protected ticket subtotal for a given order summary snapshot. */
|
|
157
|
-
function computeChangeFlowProtectedTicketSubtotalForOrderSummary(
|
|
158
|
-
sameItinerary: boolean,
|
|
159
|
-
ticketLineItems: OrderSummary['ticketLineItems'],
|
|
160
|
-
ticketFloors: Map<string, number>,
|
|
161
|
-
initialQtyByCat: Map<string, number>,
|
|
162
|
-
): number {
|
|
163
|
-
if (!sameItinerary) {
|
|
164
|
-
return ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0);
|
|
165
|
-
}
|
|
166
|
-
return ticketLineItems.reduce((sum, line) => {
|
|
167
|
-
const category = line.category?.trim().toUpperCase();
|
|
168
|
-
const qty = Math.max(0, Number(line.qty) || 0);
|
|
169
|
-
if (!category || qty <= 0) return sum;
|
|
170
|
-
const bookedUnitPrice = ticketFloors.get(category);
|
|
171
|
-
if (bookedUnitPrice == null) return sum + line.itemTotal;
|
|
172
|
-
const baselineQty = initialQtyByCat.get(category) ?? 0;
|
|
173
|
-
const protectedQty = Math.min(qty, baselineQty);
|
|
174
|
-
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
175
|
-
const liveUnit = qty > 0 ? Math.max(0, Number(line.itemTotal) || 0) / qty : 0;
|
|
176
|
-
return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnit;
|
|
177
|
-
}, 0);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function computeChangeFlowProtectedFeeSubtotalForOrderSummary(
|
|
181
|
-
sameItinerary: boolean,
|
|
182
|
-
totalQuantity: number,
|
|
183
|
-
feeLineItems: OrderSummary['feeLineItems'],
|
|
184
|
-
feeFloors: Map<string, number>,
|
|
185
|
-
initialTicketCount: number,
|
|
186
|
-
): number {
|
|
187
|
-
if (!sameItinerary || totalQuantity <= 0) {
|
|
188
|
-
return feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0);
|
|
189
|
-
}
|
|
190
|
-
const protectedP = Math.min(initialTicketCount, totalQuantity);
|
|
191
|
-
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
192
|
-
return feeLineItems.reduce((sum, line) => {
|
|
193
|
-
const key = normalizeLineLabelForCompare(line.name || '');
|
|
194
|
-
const bookedUnitPrice = key ? feeFloors.get(key) : undefined;
|
|
195
|
-
if (bookedUnitPrice == null) return sum + line.totalAmount;
|
|
196
|
-
const liveTotal = Math.max(0, Number(line.totalAmount) || 0);
|
|
197
|
-
const livePer = totalQuantity > 0 ? liveTotal / totalQuantity : 0;
|
|
198
|
-
return sum + protectedP * bookedUnitPrice + incrementalP * livePer;
|
|
199
|
-
}, 0);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function computeChangeFlowProtectedReturnForOrderSummary(
|
|
203
|
-
sameItinerary: boolean,
|
|
204
|
-
totalQuantity: number,
|
|
205
|
-
returnPriceAdjustment: number,
|
|
206
|
-
returnFloorPerPerson: number | null,
|
|
207
|
-
initialTicketCount: number,
|
|
208
|
-
selectedReturnOption: ReturnOption | null,
|
|
209
|
-
currency: Currency,
|
|
210
|
-
): number {
|
|
211
|
-
if (!sameItinerary || totalQuantity <= 0) return returnPriceAdjustment;
|
|
212
|
-
if (returnFloorPerPerson == null) return returnPriceAdjustment;
|
|
213
|
-
const rawPerPerson = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
214
|
-
const protectedP = Math.min(initialTicketCount, totalQuantity);
|
|
215
|
-
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
216
|
-
return protectedP * returnFloorPerPerson + incrementalP * rawPerPerson;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function computeChangeFlowEffectiveSubtotalBeforeAddOnsForOrderSummary(
|
|
220
|
-
isChangeFlow: boolean,
|
|
221
|
-
sameItinerary: boolean,
|
|
222
|
-
os: OrderSummary,
|
|
223
|
-
ticketFloors: Map<string, number>,
|
|
224
|
-
initialQtyByCat: Map<string, number>,
|
|
225
|
-
feeFloors: Map<string, number>,
|
|
226
|
-
initialTicketCount: number,
|
|
227
|
-
returnFloorPerPerson: number | null,
|
|
228
|
-
selectedReturnOption: ReturnOption | null,
|
|
229
|
-
currency: Currency,
|
|
230
|
-
): number {
|
|
231
|
-
const { subtotal, ticketLineItems, feeLineItems, returnPriceAdjustment, totalQuantity } = os;
|
|
232
|
-
if (!isChangeFlow || !sameItinerary) return subtotal;
|
|
233
|
-
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
234
|
-
(s, l) => s + Math.max(0, Number(l.itemTotal) || 0),
|
|
235
|
-
0,
|
|
236
|
-
);
|
|
237
|
-
const currentFeeSubtotal = feeLineItems.reduce((s, l) => s + Math.max(0, Number(l.totalAmount) || 0), 0);
|
|
238
|
-
const protectedT = computeChangeFlowProtectedTicketSubtotalForOrderSummary(
|
|
239
|
-
sameItinerary,
|
|
240
|
-
ticketLineItems,
|
|
241
|
-
ticketFloors,
|
|
242
|
-
initialQtyByCat,
|
|
243
|
-
);
|
|
244
|
-
const protectedF = computeChangeFlowProtectedFeeSubtotalForOrderSummary(
|
|
245
|
-
sameItinerary,
|
|
246
|
-
totalQuantity,
|
|
247
|
-
feeLineItems,
|
|
248
|
-
feeFloors,
|
|
249
|
-
initialTicketCount,
|
|
250
|
-
);
|
|
251
|
-
const protectedR = computeChangeFlowProtectedReturnForOrderSummary(
|
|
252
|
-
sameItinerary,
|
|
253
|
-
totalQuantity,
|
|
254
|
-
returnPriceAdjustment,
|
|
255
|
-
returnFloorPerPerson,
|
|
256
|
-
initialTicketCount,
|
|
257
|
-
selectedReturnOption,
|
|
258
|
-
currency,
|
|
259
|
-
);
|
|
260
|
-
return (
|
|
261
|
-
subtotal -
|
|
262
|
-
currentTicketSubtotal -
|
|
263
|
-
currentFeeSubtotal -
|
|
264
|
-
returnPriceAdjustment +
|
|
265
|
-
protectedT +
|
|
266
|
-
protectedF +
|
|
267
|
-
protectedR
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Matches BookingFlow totalPrice from effectiveSubtotalBeforeAddOns + add-ons + tax − promo. */
|
|
272
|
-
function computeTotalPriceFromEffectiveSubtotalBeforeAddOns(
|
|
273
|
-
effectiveSubtotalBeforeAddOns: number,
|
|
274
|
-
addOnTotal: number,
|
|
275
|
-
effectivePromoDiscountAmount: number,
|
|
276
|
-
isGiftCard: boolean,
|
|
277
|
-
isVoucher: boolean,
|
|
278
|
-
isTaxIncludedInPrice: boolean,
|
|
279
|
-
pricingConfig: PricingConfig | null,
|
|
280
|
-
): number {
|
|
281
|
-
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
282
|
-
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
|
|
283
|
-
const effectiveTax =
|
|
284
|
-
effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
285
|
-
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
286
|
-
: taxOnSubtotal;
|
|
287
|
-
return effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
157
|
function deriveAddOnSelectionsFromReceiptLines(
|
|
291
158
|
addOns: AddOn[],
|
|
292
159
|
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
@@ -346,6 +213,92 @@ function findMergedAvailabilityForSelection(
|
|
|
346
213
|
return merged.find((a) => a.dateTime === dt && a.productOptionId === optId);
|
|
347
214
|
}
|
|
348
215
|
|
|
216
|
+
/** Ticket rates for one availability row — mirrors main cart [pricing] useMemo so baseline slots match BE. */
|
|
217
|
+
function buildPricingFromAvailability(
|
|
218
|
+
selectedAvailability: Availability | null,
|
|
219
|
+
activeOptions: Product['options'],
|
|
220
|
+
precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
|
|
221
|
+
currency: Currency,
|
|
222
|
+
pricingConfig: PricingConfig | null,
|
|
223
|
+
hasFees: boolean,
|
|
224
|
+
isSimplifiedPricingView: boolean,
|
|
225
|
+
) {
|
|
226
|
+
if (!selectedAvailability || !pricingConfig) return [];
|
|
227
|
+
const optionId = selectedAvailability.productOptionId;
|
|
228
|
+
const selectedOption = activeOptions.find((opt) => opt.optionId === optionId);
|
|
229
|
+
const precomputed = optionId ? precomputedPricesByOption?.[optionId] : undefined;
|
|
230
|
+
const rateToDisplayPrice = (backendInDisplayCurrency: number) =>
|
|
231
|
+
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
232
|
+
const getBaseInDisplayCurrency = (category: string) => {
|
|
233
|
+
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
234
|
+
if (fromPrecomputed != null) return fromPrecomputed;
|
|
235
|
+
return 0;
|
|
236
|
+
};
|
|
237
|
+
const buildRate = (
|
|
238
|
+
category: string,
|
|
239
|
+
backendPriceCAD: number,
|
|
240
|
+
backendInDisplayCurrency: number,
|
|
241
|
+
baseInDisplayCurrency: number,
|
|
242
|
+
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
|
|
243
|
+
) => {
|
|
244
|
+
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
245
|
+
const isPublicMode = isSimplifiedPricingView;
|
|
246
|
+
const breakdown = computePriceBreakdown(
|
|
247
|
+
pricingConfig,
|
|
248
|
+
currency,
|
|
249
|
+
backendPriceCAD,
|
|
250
|
+
basePriceCAD,
|
|
251
|
+
hasFees,
|
|
252
|
+
appliedAdjustments,
|
|
253
|
+
undefined,
|
|
254
|
+
baseInDisplayCurrency,
|
|
255
|
+
isPublicMode,
|
|
256
|
+
);
|
|
257
|
+
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
258
|
+
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
259
|
+
};
|
|
260
|
+
return (
|
|
261
|
+
selectedAvailability.rates?.map((rate) => {
|
|
262
|
+
const backendPriceCAD = rate.price ?? 0;
|
|
263
|
+
const backendInDisplayCurrency =
|
|
264
|
+
rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
265
|
+
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
266
|
+
const built = buildRate(
|
|
267
|
+
rate.category,
|
|
268
|
+
backendPriceCAD,
|
|
269
|
+
backendInDisplayCurrency,
|
|
270
|
+
baseInDisplayCurrency,
|
|
271
|
+
rate.appliedAdjustments ?? rate.applied_adjustments ?? [],
|
|
272
|
+
);
|
|
273
|
+
return {
|
|
274
|
+
category: rate.category,
|
|
275
|
+
rateId: rate.rateId || rate.category,
|
|
276
|
+
available: rate.available,
|
|
277
|
+
price: built.price,
|
|
278
|
+
priceCAD: built.priceCAD,
|
|
279
|
+
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
280
|
+
appliedAdjustments: built.appliedAdjustments,
|
|
281
|
+
};
|
|
282
|
+
}) ||
|
|
283
|
+
selectedAvailability.pricesByCategory?.retailPrices?.map((p) => {
|
|
284
|
+
const priceCADFromApi = p.price / 100;
|
|
285
|
+
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
286
|
+
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
287
|
+
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
288
|
+
return {
|
|
289
|
+
category: p.category,
|
|
290
|
+
rateId: p.category,
|
|
291
|
+
available: selectedAvailability.vacancies,
|
|
292
|
+
price: built.price,
|
|
293
|
+
priceCAD: built.priceCAD,
|
|
294
|
+
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
295
|
+
appliedAdjustments: built.appliedAdjustments,
|
|
296
|
+
};
|
|
297
|
+
}) ||
|
|
298
|
+
[]
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
349
302
|
function findMergedReturnVacancies(
|
|
350
303
|
outbound: Availability | undefined,
|
|
351
304
|
selectedReturn: ReturnOption | null | undefined
|
|
@@ -429,12 +382,16 @@ interface BookingFlowProps {
|
|
|
429
382
|
dateTime?: string | null;
|
|
430
383
|
/** Inventory slot id from booking (when API persists it); strongest match for change flow. */
|
|
431
384
|
availabilityId?: string | null;
|
|
385
|
+
/** Parent product id when option id is omitted or differs from availability rows (GYG/ticketbooth shape). */
|
|
386
|
+
productId?: string | null;
|
|
432
387
|
productOptionId?: string | null;
|
|
433
388
|
pickupLocationId?: string | null;
|
|
434
389
|
/** Original booked return slot (round-trip), when API provides it. */
|
|
435
390
|
returnAvailabilityId?: string | null;
|
|
436
391
|
/** Fallback when only return datetime is on the booking payload. */
|
|
437
392
|
returnDateTime?: string | null;
|
|
393
|
+
/** Persisted return floor per person from booking API (must match ticketbooth `return_unit_floor_per_person`). */
|
|
394
|
+
returnUnitFloorPerPerson?: number | null;
|
|
438
395
|
bookingItems?: Array<{ category: string; count: number }> | null;
|
|
439
396
|
addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
|
|
440
397
|
customer?: {
|
|
@@ -910,6 +867,18 @@ export function BookingFlow({
|
|
|
910
867
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
911
868
|
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
912
869
|
const isChangeFlow = mode === 'change';
|
|
870
|
+
const changeFlowOriginalDate = useMemo(() => {
|
|
871
|
+
if (!isChangeFlow || !initialValues?.dateTime?.trim()) return null;
|
|
872
|
+
try {
|
|
873
|
+
return formatInTimeZone(
|
|
874
|
+
parseAvailabilityDateTime(initialValues.dateTime.trim()),
|
|
875
|
+
companyTimezone,
|
|
876
|
+
'yyyy-MM-dd',
|
|
877
|
+
);
|
|
878
|
+
} catch {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}, [isChangeFlow, initialValues?.dateTime, companyTimezone]);
|
|
913
882
|
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
914
883
|
const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
|
|
915
884
|
|
|
@@ -1720,6 +1689,79 @@ export function BookingFlow({
|
|
|
1720
1689
|
[timesForSelectedDate],
|
|
1721
1690
|
);
|
|
1722
1691
|
|
|
1692
|
+
/**
|
|
1693
|
+
* Bookings often store only parent `p_…` (no `po_…`). Resolve the booked option id from inventory rows,
|
|
1694
|
+
* wall-clock match, or single active option — must be defined before effects that seed selection / seat credit.
|
|
1695
|
+
*/
|
|
1696
|
+
const changeFlowResolvedInitialProductOptionId = useMemo((): string | null => {
|
|
1697
|
+
if (!isChangeFlow) return null;
|
|
1698
|
+
const fromFields = effectiveProductOptionIdForChangeFlow({
|
|
1699
|
+
productId: initialValues?.productId,
|
|
1700
|
+
productOptionId: initialValues?.productOptionId,
|
|
1701
|
+
});
|
|
1702
|
+
if (fromFields) return fromFields;
|
|
1703
|
+
|
|
1704
|
+
const aid = initialValues?.availabilityId?.trim();
|
|
1705
|
+
if (aid) {
|
|
1706
|
+
for (const a of availabilities) {
|
|
1707
|
+
if (a.availabilityId === aid) {
|
|
1708
|
+
const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
|
|
1709
|
+
if (po) return po;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (initialValues?.dateTime?.trim()) {
|
|
1715
|
+
try {
|
|
1716
|
+
const initialDt = parseAvailabilityDateTime(initialValues.dateTime.trim());
|
|
1717
|
+
const initialMs = initialDt.getTime();
|
|
1718
|
+
const initialDay = formatInTimeZone(initialDt, companyTimezone, 'yyyy-MM-dd');
|
|
1719
|
+
for (const a of availabilities) {
|
|
1720
|
+
const avDt = parseAvailabilityDateTime(a.dateTime);
|
|
1721
|
+
if (formatInTimeZone(avDt, companyTimezone, 'yyyy-MM-dd') !== initialDay) continue;
|
|
1722
|
+
if (avDt.getTime() !== initialMs) continue;
|
|
1723
|
+
const po = normalizeProductOptionIdForChangeFlow(a.productOptionId);
|
|
1724
|
+
if (po) return po;
|
|
1725
|
+
}
|
|
1726
|
+
} catch {
|
|
1727
|
+
/* ignore */
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (activeOptions.length === 1) {
|
|
1732
|
+
return normalizeProductOptionIdForChangeFlow(activeOptions[0].optionId);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
return null;
|
|
1736
|
+
}, [
|
|
1737
|
+
isChangeFlow,
|
|
1738
|
+
initialValues?.productId,
|
|
1739
|
+
initialValues?.productOptionId,
|
|
1740
|
+
initialValues?.availabilityId,
|
|
1741
|
+
initialValues?.dateTime,
|
|
1742
|
+
availabilities,
|
|
1743
|
+
activeOptions,
|
|
1744
|
+
companyTimezone,
|
|
1745
|
+
]);
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Parent catalog product id (`p_…`) for minimum-paid receipt floors: explicit parent on the booking,
|
|
1749
|
+
* otherwise the product loaded for this change session (booking payload may only carry `po_…`).
|
|
1750
|
+
*/
|
|
1751
|
+
const changeFlowBookingParentProductIdForFloors = useMemo(() => {
|
|
1752
|
+
const pid = initialValues?.productId?.trim();
|
|
1753
|
+
if (pid && isParentProductId(pid)) return pid;
|
|
1754
|
+
return product.productId.trim();
|
|
1755
|
+
}, [initialValues?.productId, product.productId]);
|
|
1756
|
+
|
|
1757
|
+
/** Minimum paid price / receipt anchoring applies for any option under this parent product. */
|
|
1758
|
+
const changeFlowApplyReceiptPaidFloors = useMemo(
|
|
1759
|
+
() =>
|
|
1760
|
+
isChangeFlow &&
|
|
1761
|
+
changeFlowBookingParentProductIdForFloors === product.productId.trim(),
|
|
1762
|
+
[isChangeFlow, changeFlowBookingParentProductIdForFloors, product.productId],
|
|
1763
|
+
);
|
|
1764
|
+
|
|
1723
1765
|
useEffect(() => {
|
|
1724
1766
|
if (hasAppliedInitialValuesRef.current || !initialValues) return;
|
|
1725
1767
|
const trimmedEmail = initialValues.customer?.email?.trim();
|
|
@@ -1760,7 +1802,7 @@ export function BookingFlow({
|
|
|
1760
1802
|
target,
|
|
1761
1803
|
companyTimezone,
|
|
1762
1804
|
initialValues.availabilityId ?? null,
|
|
1763
|
-
initialValues.productOptionId ?? null,
|
|
1805
|
+
changeFlowResolvedInitialProductOptionId ?? initialValues.productOptionId ?? null,
|
|
1764
1806
|
initialValues.bookingItems ?? null,
|
|
1765
1807
|
precomputedPricesByOption,
|
|
1766
1808
|
currency,
|
|
@@ -1776,6 +1818,7 @@ export function BookingFlow({
|
|
|
1776
1818
|
initialValues?.availabilityId,
|
|
1777
1819
|
initialValues?.productOptionId,
|
|
1778
1820
|
initialValues?.bookingItems,
|
|
1821
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1779
1822
|
selectedAvailability,
|
|
1780
1823
|
selectedDate,
|
|
1781
1824
|
timesForSelectedDate,
|
|
@@ -1943,14 +1986,20 @@ export function BookingFlow({
|
|
|
1943
1986
|
const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
|
|
1944
1987
|
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
1945
1988
|
if (selectedMs !== initialMs) return 0;
|
|
1946
|
-
const
|
|
1947
|
-
const
|
|
1948
|
-
if (
|
|
1949
|
-
|
|
1989
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(availability.productOptionId);
|
|
1990
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
1991
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
1992
|
+
return selectedOpt === initialOpt ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1993
|
+
}
|
|
1994
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
1995
|
+
const legacySelected = availability.productOptionId?.trim() || null;
|
|
1996
|
+
if (!legacySelected || !legacyInitial) return changeFlowInitialTicketCountForSeatCredit;
|
|
1997
|
+
return legacySelected === legacyInitial ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1950
1998
|
},
|
|
1951
1999
|
[
|
|
1952
2000
|
isChangeFlow,
|
|
1953
2001
|
changeFlowInitialTicketCountForSeatCredit,
|
|
2002
|
+
changeFlowResolvedInitialProductOptionId,
|
|
1954
2003
|
initialValues?.availabilityId,
|
|
1955
2004
|
initialValues?.dateTime,
|
|
1956
2005
|
initialValues?.productOptionId,
|
|
@@ -2298,7 +2347,11 @@ export function BookingFlow({
|
|
|
2298
2347
|
return floors;
|
|
2299
2348
|
}, [isChangeFlow, originalReceipt?.lineItems]);
|
|
2300
2349
|
|
|
2301
|
-
|
|
2350
|
+
/**
|
|
2351
|
+
* When the API omits `returnUnitFloorPerPerson`, derive per-person paid return from the stored receipt
|
|
2352
|
+
* so catalog "free" return slots still show and price at the original return value in change flow.
|
|
2353
|
+
*/
|
|
2354
|
+
const changeFlowReturnUnitFloorFromReceipt = useMemo(() => {
|
|
2302
2355
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return null;
|
|
2303
2356
|
const fallbackBookedQty =
|
|
2304
2357
|
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
@@ -2306,7 +2359,7 @@ export function BookingFlow({
|
|
|
2306
2359
|
let totalQty = 0;
|
|
2307
2360
|
for (const line of originalReceipt.lineItems) {
|
|
2308
2361
|
const type = (line.type || '').trim().toUpperCase();
|
|
2309
|
-
if (type !== 'RETURN_OPTION') continue;
|
|
2362
|
+
if (type !== 'RETURN_OPTION' && type !== 'RETURN') continue;
|
|
2310
2363
|
const amount = Number(line.amount ?? 0);
|
|
2311
2364
|
const qtyRaw = Number(line.quantity ?? 0);
|
|
2312
2365
|
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
@@ -2317,6 +2370,15 @@ export function BookingFlow({
|
|
|
2317
2370
|
if (totalAmount <= 0 || totalQty <= 0) return null;
|
|
2318
2371
|
return totalAmount / totalQty;
|
|
2319
2372
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2373
|
+
|
|
2374
|
+
const effectiveChangeFlowReturnUnitFloorPerPerson = useMemo(() => {
|
|
2375
|
+
if (!isChangeFlow) return null;
|
|
2376
|
+
const fromApi = Number(initialValues?.returnUnitFloorPerPerson ?? 0);
|
|
2377
|
+
if (Number.isFinite(fromApi) && fromApi > 0) return fromApi;
|
|
2378
|
+
const fromReceipt = changeFlowReturnUnitFloorFromReceipt;
|
|
2379
|
+
if (fromReceipt != null && fromReceipt > 0) return fromReceipt;
|
|
2380
|
+
return null;
|
|
2381
|
+
}, [isChangeFlow, initialValues?.returnUnitFloorPerPerson, changeFlowReturnUnitFloorFromReceipt]);
|
|
2320
2382
|
const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
|
|
2321
2383
|
const feeUnitByLabel = new Map<string, number>();
|
|
2322
2384
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
|
|
@@ -2360,19 +2422,25 @@ export function BookingFlow({
|
|
|
2360
2422
|
Boolean(initialAvailabilityId && selectedAvailabilityId) &&
|
|
2361
2423
|
initialAvailabilityId === selectedAvailabilityId;
|
|
2362
2424
|
if (idsMatch) return true;
|
|
2363
|
-
// Same wall time +
|
|
2425
|
+
// Same wall time + same product option (ids may rotate on refresh).
|
|
2364
2426
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2365
2427
|
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2366
2428
|
if (selectedMs !== initialMs) return false;
|
|
2367
|
-
const
|
|
2368
|
-
const
|
|
2369
|
-
|
|
2429
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
2430
|
+
const initialOpt = changeFlowResolvedInitialProductOptionId;
|
|
2431
|
+
if (selectedOpt != null && initialOpt != null) {
|
|
2432
|
+
return selectedOpt === initialOpt;
|
|
2433
|
+
}
|
|
2434
|
+
const legacyInitial = initialValues.productOptionId?.trim() || null;
|
|
2435
|
+
const legacySelected = selectedAvailability.productOptionId?.trim() || null;
|
|
2436
|
+
return !legacySelected || !legacyInitial || legacySelected === legacyInitial;
|
|
2370
2437
|
}, [
|
|
2371
2438
|
isChangeFlow,
|
|
2372
2439
|
selectedAvailability,
|
|
2373
2440
|
initialValues?.availabilityId,
|
|
2374
2441
|
initialValues?.dateTime,
|
|
2375
2442
|
initialValues?.productOptionId,
|
|
2443
|
+
changeFlowResolvedInitialProductOptionId,
|
|
2376
2444
|
]);
|
|
2377
2445
|
const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
|
|
2378
2446
|
if (!isChangeFlow) return false;
|
|
@@ -2420,15 +2488,22 @@ export function BookingFlow({
|
|
|
2420
2488
|
|
|
2421
2489
|
const returnOptionsWithFloor = useMemo(() => {
|
|
2422
2490
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
2423
|
-
if (!isChangeFlow &&
|
|
2491
|
+
if (!isChangeFlow && effectiveChangeFlowReturnUnitFloorPerPerson == null) return options;
|
|
2424
2492
|
return options.map((opt) => {
|
|
2425
2493
|
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2426
2494
|
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2427
2495
|
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2428
2496
|
const applyReturnFloor =
|
|
2429
|
-
|
|
2497
|
+
effectiveChangeFlowReturnUnitFloorPerPerson != null &&
|
|
2498
|
+
(
|
|
2499
|
+
// Public self-serve change flow should always display the paid return floor on cards.
|
|
2500
|
+
isCustomerSelfServeChange ||
|
|
2501
|
+
// For non-public paths, only apply floor after itinerary changes.
|
|
2502
|
+
!isChangeFlow ||
|
|
2503
|
+
!changeFlowSameItineraryAsOriginalBooking
|
|
2504
|
+
);
|
|
2430
2505
|
const flooredPerPerson = applyReturnFloor
|
|
2431
|
-
? Math.max(rawPerPerson,
|
|
2506
|
+
? Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson!)
|
|
2432
2507
|
: rawPerPerson;
|
|
2433
2508
|
if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
|
|
2434
2509
|
return {
|
|
@@ -2443,17 +2518,18 @@ export function BookingFlow({
|
|
|
2443
2518
|
}, [
|
|
2444
2519
|
selectedAvailability?.returnOptions,
|
|
2445
2520
|
isChangeFlow,
|
|
2446
|
-
|
|
2521
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2447
2522
|
changeFlowSameItineraryAsOriginalBooking,
|
|
2523
|
+
isCustomerSelfServeChange,
|
|
2448
2524
|
currency,
|
|
2449
2525
|
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2450
2526
|
]);
|
|
2451
2527
|
|
|
2452
2528
|
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2453
|
-
if (!selectedReturnOption || !isChangeFlow ||
|
|
2454
|
-
if (changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
|
|
2529
|
+
if (!selectedReturnOption || !isChangeFlow || effectiveChangeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
|
|
2530
|
+
if (!isCustomerSelfServeChange && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
|
|
2455
2531
|
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2456
|
-
const flooredPerPerson = Math.max(rawPerPerson,
|
|
2532
|
+
const flooredPerPerson = Math.max(rawPerPerson, effectiveChangeFlowReturnUnitFloorPerPerson);
|
|
2457
2533
|
if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
|
|
2458
2534
|
return {
|
|
2459
2535
|
...selectedReturnOption,
|
|
@@ -2465,15 +2541,30 @@ export function BookingFlow({
|
|
|
2465
2541
|
}, [
|
|
2466
2542
|
selectedReturnOption,
|
|
2467
2543
|
isChangeFlow,
|
|
2468
|
-
|
|
2544
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2469
2545
|
changeFlowSameItineraryAsOriginalBooking,
|
|
2546
|
+
isCustomerSelfServeChange,
|
|
2470
2547
|
currency,
|
|
2471
2548
|
]);
|
|
2472
2549
|
|
|
2473
2550
|
const returnOptionForOrderSummary = useMemo(() => {
|
|
2474
|
-
|
|
2551
|
+
// Same itinerary + dashboard change: keep raw return option (floor only after itinerary changes).
|
|
2552
|
+
// Public self-serve: always apply API return floor on options so totals match paid return.
|
|
2553
|
+
if (
|
|
2554
|
+
isChangeFlow &&
|
|
2555
|
+
changeFlowSameItineraryAsOriginalBooking &&
|
|
2556
|
+
!isCustomerSelfServeChange
|
|
2557
|
+
) {
|
|
2558
|
+
return selectedReturnOption ?? null;
|
|
2559
|
+
}
|
|
2475
2560
|
return selectedReturnOptionWithFloor;
|
|
2476
|
-
}, [
|
|
2561
|
+
}, [
|
|
2562
|
+
isChangeFlow,
|
|
2563
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2564
|
+
isCustomerSelfServeChange,
|
|
2565
|
+
selectedReturnOption,
|
|
2566
|
+
selectedReturnOptionWithFloor,
|
|
2567
|
+
]);
|
|
2477
2568
|
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2478
2569
|
if (!selectedAvailability) return 0;
|
|
2479
2570
|
const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
|
|
@@ -2488,71 +2579,19 @@ export function BookingFlow({
|
|
|
2488
2579
|
}, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
|
|
2489
2580
|
|
|
2490
2581
|
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2491
|
-
const pricing = useMemo(
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
getDisplayPriceFromBaseInDisplayCurrency(backendInDisplayCurrency, currency, pricingConfig, hasFees);
|
|
2498
|
-
const getBaseInDisplayCurrency = (category: string) => {
|
|
2499
|
-
const fromPrecomputed = precomputed?.[category]?.[currency];
|
|
2500
|
-
if (fromPrecomputed != null) return fromPrecomputed;
|
|
2501
|
-
return 0;
|
|
2502
|
-
};
|
|
2503
|
-
const buildRate = (
|
|
2504
|
-
category: string,
|
|
2505
|
-
backendPriceCAD: number,
|
|
2506
|
-
backendInDisplayCurrency: number,
|
|
2507
|
-
baseInDisplayCurrency: number,
|
|
2508
|
-
appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>
|
|
2509
|
-
) => {
|
|
2510
|
-
const basePriceCAD = selectedOption?.pricing?.[category.toUpperCase()] ?? 0;
|
|
2511
|
-
const isPublicMode = isSimplifiedPricingView;
|
|
2512
|
-
const breakdown = computePriceBreakdown(
|
|
2513
|
-
pricingConfig,
|
|
2582
|
+
const pricing = useMemo(
|
|
2583
|
+
() =>
|
|
2584
|
+
buildPricingFromAvailability(
|
|
2585
|
+
selectedAvailability,
|
|
2586
|
+
activeOptions,
|
|
2587
|
+
precomputedPricesByOption,
|
|
2514
2588
|
currency,
|
|
2515
|
-
|
|
2516
|
-
basePriceCAD,
|
|
2589
|
+
pricingConfig,
|
|
2517
2590
|
hasFees,
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
);
|
|
2523
|
-
const price = breakdown?.finalPrice ?? rateToDisplayPrice(backendInDisplayCurrency);
|
|
2524
|
-
return { category, baseInDisplayCurrency, appliedAdjustments, price, priceCAD: backendPriceCAD };
|
|
2525
|
-
};
|
|
2526
|
-
return selectedAvailability.rates?.map(rate => {
|
|
2527
|
-
const backendPriceCAD = rate.price ?? 0;
|
|
2528
|
-
const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
2529
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
2530
|
-
const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
|
|
2531
|
-
return {
|
|
2532
|
-
category: rate.category,
|
|
2533
|
-
rateId: rate.rateId || rate.category,
|
|
2534
|
-
available: rate.available,
|
|
2535
|
-
price: built.price,
|
|
2536
|
-
priceCAD: built.priceCAD,
|
|
2537
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2538
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
2539
|
-
};
|
|
2540
|
-
}) || selectedAvailability.pricesByCategory?.retailPrices?.map(p => {
|
|
2541
|
-
const priceCADFromApi = p.price / 100;
|
|
2542
|
-
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
2543
|
-
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
2544
|
-
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
2545
|
-
return {
|
|
2546
|
-
category: p.category,
|
|
2547
|
-
rateId: p.category,
|
|
2548
|
-
available: selectedAvailability.vacancies,
|
|
2549
|
-
price: built.price,
|
|
2550
|
-
priceCAD: built.priceCAD,
|
|
2551
|
-
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2552
|
-
appliedAdjustments: built.appliedAdjustments,
|
|
2553
|
-
};
|
|
2554
|
-
}) || [];
|
|
2555
|
-
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
|
|
2591
|
+
isSimplifiedPricingView,
|
|
2592
|
+
),
|
|
2593
|
+
[selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView],
|
|
2594
|
+
);
|
|
2556
2595
|
|
|
2557
2596
|
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2558
2597
|
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
@@ -2588,62 +2627,29 @@ export function BookingFlow({
|
|
|
2588
2627
|
[quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2589
2628
|
);
|
|
2590
2629
|
|
|
2591
|
-
/** Initial booking ticket counts on the same pricing grid (for catalog delta vs receipt anchoring). */
|
|
2592
|
-
const changeFlowBaselineQuantitiesForSummary = useMemo((): Record<string, number> => {
|
|
2593
|
-
const next: Record<string, number> = {};
|
|
2594
|
-
for (const rate of pricing) {
|
|
2595
|
-
next[rate.category] = 0;
|
|
2596
|
-
}
|
|
2597
|
-
for (const item of initialValues?.bookingItems ?? []) {
|
|
2598
|
-
const c = item.category?.trim();
|
|
2599
|
-
if (c) next[c] = Math.max(0, Number(item.count) || 0);
|
|
2600
|
-
}
|
|
2601
|
-
return next;
|
|
2602
|
-
}, [pricing, initialValues?.bookingItems]);
|
|
2603
|
-
|
|
2604
|
-
const orderSummaryChangeFlowBaseline: OrderSummary = useMemo(
|
|
2605
|
-
() =>
|
|
2606
|
-
computeOrderSummary(
|
|
2607
|
-
changeFlowBaselineQuantitiesForSummary,
|
|
2608
|
-
pricing,
|
|
2609
|
-
returnOptionForOrderSummary,
|
|
2610
|
-
pricingConfig ?? null,
|
|
2611
|
-
currency,
|
|
2612
|
-
hasFees,
|
|
2613
|
-
cancellationPolicyId
|
|
2614
|
-
),
|
|
2615
|
-
[
|
|
2616
|
-
changeFlowBaselineQuantitiesForSummary,
|
|
2617
|
-
pricing,
|
|
2618
|
-
returnOptionForOrderSummary,
|
|
2619
|
-
pricingConfig,
|
|
2620
|
-
currency,
|
|
2621
|
-
hasFees,
|
|
2622
|
-
cancellationPolicyId,
|
|
2623
|
-
],
|
|
2624
|
-
);
|
|
2625
|
-
|
|
2626
2630
|
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2627
2631
|
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2628
2632
|
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2629
2633
|
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2630
2634
|
0,
|
|
2631
2635
|
);
|
|
2632
|
-
if (!
|
|
2636
|
+
if (!changeFlowApplyReceiptPaidFloors) return currentTicketSubtotal;
|
|
2633
2637
|
return ticketLineItems.reduce((sum, line) => {
|
|
2634
2638
|
const category = line.category?.trim().toUpperCase();
|
|
2635
2639
|
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2636
2640
|
if (!category || qty <= 0) return sum;
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2641
|
+
return (
|
|
2642
|
+
sum +
|
|
2643
|
+
changeFlowTicketLineTotalWithReceiptFloor({
|
|
2644
|
+
qty,
|
|
2645
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2646
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2647
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2648
|
+
})
|
|
2649
|
+
);
|
|
2644
2650
|
}, 0);
|
|
2645
2651
|
}, [
|
|
2646
|
-
|
|
2652
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2647
2653
|
ticketLineItems,
|
|
2648
2654
|
changeFlowTicketBookedUnitPriceByCategory,
|
|
2649
2655
|
changeFlowInitialTicketQtyByCategory,
|
|
@@ -2736,45 +2742,98 @@ export function BookingFlow({
|
|
|
2736
2742
|
[feeLineItems],
|
|
2737
2743
|
);
|
|
2738
2744
|
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2739
|
-
if (!
|
|
2745
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2740
2746
|
const initialParty = changeFlowInitialTicketCount;
|
|
2741
|
-
const protectedP = Math.min(initialParty, totalQuantity);
|
|
2742
|
-
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
2743
2747
|
return feeLineItems.reduce((sum, line) => {
|
|
2744
2748
|
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2745
|
-
const
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2749
|
+
const bookedFeePerPerson = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : undefined;
|
|
2750
|
+
return (
|
|
2751
|
+
sum +
|
|
2752
|
+
changeFlowFeeLineTotalWithReceiptFloor({
|
|
2753
|
+
totalQuantity,
|
|
2754
|
+
initialTicketCount: initialParty,
|
|
2755
|
+
bookedFeePerPerson,
|
|
2756
|
+
liveFeeLineTotal: Number(line.totalAmount) || 0,
|
|
2757
|
+
})
|
|
2758
|
+
);
|
|
2750
2759
|
}, 0);
|
|
2751
2760
|
}, [
|
|
2752
|
-
|
|
2761
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2753
2762
|
totalQuantity,
|
|
2754
2763
|
currentFeeSubtotal,
|
|
2755
2764
|
feeLineItems,
|
|
2756
2765
|
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2757
2766
|
changeFlowInitialTicketCount,
|
|
2758
2767
|
]);
|
|
2768
|
+
/** Catalog (unfloored) return price per person — same slot as [selectedReturnOption] on the raw availability list. */
|
|
2769
|
+
const returnOptionCatalogPerPerson = useMemo(() => {
|
|
2770
|
+
if (!selectedReturnOption?.returnAvailabilityId || !selectedAvailability?.returnOptions?.length) {
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
const raw = selectedAvailability.returnOptions.find(
|
|
2774
|
+
(o) => o.returnAvailabilityId === selectedReturnOption.returnAvailabilityId
|
|
2775
|
+
);
|
|
2776
|
+
const v = raw?.priceAdjustmentByCurrency?.[currency];
|
|
2777
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
2778
|
+
}, [selectedReturnOption?.returnAvailabilityId, selectedAvailability?.returnOptions, currency]);
|
|
2779
|
+
|
|
2759
2780
|
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2760
|
-
if (!
|
|
2761
|
-
if (
|
|
2762
|
-
const
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
|
|
2766
|
-
|
|
2781
|
+
if (!changeFlowApplyReceiptPaidFloors || totalQuantity <= 0) return returnPriceAdjustment;
|
|
2782
|
+
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2783
|
+
const livePerPerson =
|
|
2784
|
+
returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
|
|
2785
|
+
const perPerson = changeFlowReturnPerPersonWithReceiptFloor({
|
|
2786
|
+
livePerPerson,
|
|
2787
|
+
receiptFloorPerPerson: effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2788
|
+
});
|
|
2789
|
+
return totalQuantity * perPerson;
|
|
2767
2790
|
}, [
|
|
2768
|
-
|
|
2791
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2769
2792
|
totalQuantity,
|
|
2770
2793
|
returnPriceAdjustment,
|
|
2771
|
-
|
|
2772
|
-
changeFlowInitialTicketCount,
|
|
2794
|
+
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2773
2795
|
selectedReturnOption,
|
|
2796
|
+
returnOptionCatalogPerPerson,
|
|
2774
2797
|
currency,
|
|
2775
2798
|
]);
|
|
2799
|
+
|
|
2800
|
+
/** Return row amount for PriceSummary, Stripe breakdown, and CheckoutModal (catalog vs protected same-product-option). */
|
|
2801
|
+
const checkoutReturnLineAmount = useMemo(() => {
|
|
2802
|
+
if (isChangeFlow && changeFlowApplyReceiptPaidFloors) {
|
|
2803
|
+
return changeFlowProtectedReturnAdjustment;
|
|
2804
|
+
}
|
|
2805
|
+
return returnPriceAdjustment;
|
|
2806
|
+
}, [
|
|
2807
|
+
isChangeFlow,
|
|
2808
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2809
|
+
changeFlowProtectedReturnAdjustment,
|
|
2810
|
+
returnPriceAdjustment,
|
|
2811
|
+
]);
|
|
2812
|
+
|
|
2813
|
+
/** Ticket lines with receipt floors applied for breakdown/modal (matches protected ticket subtotal). */
|
|
2814
|
+
const ticketLineItemsForChangeFlowDisplay = useMemo(() => {
|
|
2815
|
+
if (!changeFlowApplyReceiptPaidFloors) return ticketLineItems;
|
|
2816
|
+
return ticketLineItems.map((line) => {
|
|
2817
|
+
const category = line.category?.trim().toUpperCase();
|
|
2818
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2819
|
+
if (!category || qty <= 0) return line;
|
|
2820
|
+
const newTotal = changeFlowTicketLineTotalWithReceiptFloor({
|
|
2821
|
+
qty,
|
|
2822
|
+
baselineQtyForCategory: changeFlowInitialTicketQtyByCategory.get(category) ?? 0,
|
|
2823
|
+
receiptUnitFloor: changeFlowTicketBookedUnitPriceByCategory.get(category),
|
|
2824
|
+
liveLineTotal: Number(line.itemTotal) || 0,
|
|
2825
|
+
});
|
|
2826
|
+
return { ...line, itemTotal: newTotal };
|
|
2827
|
+
});
|
|
2828
|
+
}, [
|
|
2829
|
+
changeFlowApplyReceiptPaidFloors,
|
|
2830
|
+
ticketLineItems,
|
|
2831
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2832
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2833
|
+
]);
|
|
2834
|
+
|
|
2776
2835
|
const effectiveSubtotalBeforeAddOns =
|
|
2777
|
-
isChangeFlow &&
|
|
2836
|
+
isChangeFlow && changeFlowApplyReceiptPaidFloors
|
|
2778
2837
|
? subtotal -
|
|
2779
2838
|
currentTicketSubtotal -
|
|
2780
2839
|
currentFeeSubtotal -
|
|
@@ -2841,8 +2900,11 @@ export function BookingFlow({
|
|
|
2841
2900
|
|
|
2842
2901
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2843
2902
|
if (!selectedAvailability) return [];
|
|
2903
|
+
const returnLineAmount = checkoutReturnLineAmount;
|
|
2904
|
+
const showReturnLine =
|
|
2905
|
+
Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
|
|
2844
2906
|
return [
|
|
2845
|
-
...
|
|
2907
|
+
...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
|
|
2846
2908
|
const rate = pricing.find((r) => r.category === line.category);
|
|
2847
2909
|
const breakdown = getPriceBreakdown(
|
|
2848
2910
|
line.category,
|
|
@@ -2865,12 +2927,12 @@ export function BookingFlow({
|
|
|
2865
2927
|
breakdown,
|
|
2866
2928
|
};
|
|
2867
2929
|
}),
|
|
2868
|
-
...(
|
|
2930
|
+
...(showReturnLine
|
|
2869
2931
|
? [
|
|
2870
2932
|
{
|
|
2871
2933
|
kind: 'line' as const,
|
|
2872
2934
|
label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
|
|
2873
|
-
amount:
|
|
2935
|
+
amount: returnLineAmount,
|
|
2874
2936
|
type: 'return',
|
|
2875
2937
|
},
|
|
2876
2938
|
]
|
|
@@ -2925,11 +2987,11 @@ export function BookingFlow({
|
|
|
2925
2987
|
];
|
|
2926
2988
|
}, [
|
|
2927
2989
|
selectedAvailability,
|
|
2928
|
-
|
|
2990
|
+
ticketLineItemsForChangeFlowDisplay,
|
|
2929
2991
|
pricing,
|
|
2930
2992
|
getPriceBreakdown,
|
|
2931
2993
|
selectedReturnOption,
|
|
2932
|
-
|
|
2994
|
+
checkoutReturnLineAmount,
|
|
2933
2995
|
totalQuantity,
|
|
2934
2996
|
t,
|
|
2935
2997
|
cancellationPolicyFee,
|
|
@@ -3066,93 +3128,13 @@ export function BookingFlow({
|
|
|
3066
3128
|
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3067
3129
|
: taxOnSubtotal;
|
|
3068
3130
|
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
3069
|
-
/**
|
|
3070
|
-
const
|
|
3071
|
-
? reconcileChangeBookingProposedTotal(Math.round(totalPrice * 100) / 100, originalReceipt.total)
|
|
3072
|
-
: totalPrice;
|
|
3073
|
-
|
|
3074
|
-
const changeFlowBaselineEffectiveSubtotalBeforeAddOns = useMemo(() => {
|
|
3075
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveSubtotalBeforeAddOns;
|
|
3076
|
-
return computeChangeFlowEffectiveSubtotalBeforeAddOnsForOrderSummary(
|
|
3077
|
-
true,
|
|
3078
|
-
true,
|
|
3079
|
-
orderSummaryChangeFlowBaseline,
|
|
3080
|
-
changeFlowTicketBookedUnitPriceByCategory,
|
|
3081
|
-
changeFlowInitialTicketQtyByCategory,
|
|
3082
|
-
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
3083
|
-
changeFlowInitialTicketCount,
|
|
3084
|
-
changeFlowReturnUnitFloorPerPerson,
|
|
3085
|
-
selectedReturnOption,
|
|
3086
|
-
currency,
|
|
3087
|
-
);
|
|
3088
|
-
}, [
|
|
3089
|
-
isChangeFlow,
|
|
3090
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3091
|
-
effectiveSubtotalBeforeAddOns,
|
|
3092
|
-
orderSummaryChangeFlowBaseline,
|
|
3093
|
-
changeFlowTicketBookedUnitPriceByCategory,
|
|
3094
|
-
changeFlowInitialTicketQtyByCategory,
|
|
3095
|
-
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
3096
|
-
changeFlowInitialTicketCount,
|
|
3097
|
-
changeFlowReturnUnitFloorPerPerson,
|
|
3098
|
-
selectedReturnOption,
|
|
3099
|
-
currency,
|
|
3100
|
-
]);
|
|
3101
|
-
|
|
3102
|
-
const changeFlowBaselineEffectiveSubtotalFull = useMemo(() => {
|
|
3103
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveSubtotal;
|
|
3104
|
-
return changeFlowBaselineEffectiveSubtotalBeforeAddOns + addOnTotal;
|
|
3105
|
-
}, [
|
|
3106
|
-
isChangeFlow,
|
|
3107
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3108
|
-
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3109
|
-
addOnTotal,
|
|
3110
|
-
effectiveSubtotal,
|
|
3111
|
-
]);
|
|
3112
|
-
|
|
3113
|
-
const changeFlowBaselineEffectiveTax = useMemo(() => {
|
|
3114
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) return effectiveTax;
|
|
3115
|
-
const es = changeFlowBaselineEffectiveSubtotalFull;
|
|
3116
|
-
const taxOnSubtotal = isTaxIncludedInPrice ? 0 : es * (pricingConfig?.taxRate ?? 0);
|
|
3117
|
-
return effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
|
|
3118
|
-
? (es - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
3119
|
-
: taxOnSubtotal;
|
|
3120
|
-
}, [
|
|
3131
|
+
/** Change-flow product rules: `lib/booking/change-flow-pricing.ts`. */
|
|
3132
|
+
const changeFlowNewBookingTotal = resolveChangeFlowNewBookingTotal({
|
|
3121
3133
|
isChangeFlow,
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
isTaxIncludedInPrice,
|
|
3126
|
-
pricingConfig?.taxRate,
|
|
3127
|
-
effectivePromoDiscountAmount,
|
|
3128
|
-
isGiftCard,
|
|
3129
|
-
isVoucher,
|
|
3130
|
-
]);
|
|
3134
|
+
cartTotal: totalPrice,
|
|
3135
|
+
originalReceiptTotal: originalReceipt?.total,
|
|
3136
|
+
});
|
|
3131
3137
|
|
|
3132
|
-
const changeFlowBaselineTotalPrice = useMemo(() => {
|
|
3133
|
-
if (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking) {
|
|
3134
|
-
return totalPrice;
|
|
3135
|
-
}
|
|
3136
|
-
return computeTotalPriceFromEffectiveSubtotalBeforeAddOns(
|
|
3137
|
-
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3138
|
-
addOnTotal,
|
|
3139
|
-
effectivePromoDiscountAmount,
|
|
3140
|
-
isGiftCard,
|
|
3141
|
-
isVoucher,
|
|
3142
|
-
isTaxIncludedInPrice,
|
|
3143
|
-
pricingConfig,
|
|
3144
|
-
);
|
|
3145
|
-
}, [
|
|
3146
|
-
isChangeFlow,
|
|
3147
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3148
|
-
changeFlowBaselineEffectiveSubtotalBeforeAddOns,
|
|
3149
|
-
addOnTotal,
|
|
3150
|
-
effectivePromoDiscountAmount,
|
|
3151
|
-
isGiftCard,
|
|
3152
|
-
isVoucher,
|
|
3153
|
-
isTaxIncludedInPrice,
|
|
3154
|
-
pricingConfig,
|
|
3155
|
-
]);
|
|
3156
3138
|
const changeSelectionDetails = useMemo(() => {
|
|
3157
3139
|
if (!isChangeFlow || !initialValues) {
|
|
3158
3140
|
return {
|
|
@@ -3184,11 +3166,12 @@ export function BookingFlow({
|
|
|
3184
3166
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
3185
3167
|
// Only treat as date change when we have an original datetime to compare (otherwise we’d always flag “changed”).
|
|
3186
3168
|
const dateChanged = initialMs != null && initialMs !== selectedMs;
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3169
|
+
const initialOpt =
|
|
3170
|
+
changeFlowResolvedInitialProductOptionId ??
|
|
3171
|
+
(initialValues.productOptionId?.trim() || null);
|
|
3172
|
+
const selectedOpt = normalizeProductOptionIdForChangeFlow(selectedAvailability.productOptionId);
|
|
3190
3173
|
const optionChanged = Boolean(
|
|
3191
|
-
|
|
3174
|
+
selectedOpt != null && initialOpt != null && initialOpt !== selectedOpt
|
|
3192
3175
|
);
|
|
3193
3176
|
const normalizePickupId = (value: string | null | undefined) => {
|
|
3194
3177
|
const trimmed = value?.trim();
|
|
@@ -3288,6 +3271,7 @@ export function BookingFlow({
|
|
|
3288
3271
|
}, [
|
|
3289
3272
|
isChangeFlow,
|
|
3290
3273
|
initialValues,
|
|
3274
|
+
changeFlowResolvedInitialProductOptionId,
|
|
3291
3275
|
selectedAvailability,
|
|
3292
3276
|
selectedReturnOption,
|
|
3293
3277
|
implicitReturnBaselineId,
|
|
@@ -3349,167 +3333,29 @@ export function BookingFlow({
|
|
|
3349
3333
|
const hasEffectiveChangeSelection =
|
|
3350
3334
|
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
3351
3335
|
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
const
|
|
3363
|
-
isChangeFlow && originalReceipt
|
|
3364
|
-
? reconcileChangeBookingProposedTotal(
|
|
3365
|
-
Math.round(changeFlowReceiptAnchoredCatalogTotalRaw * 100) / 100,
|
|
3366
|
-
originalReceipt.total,
|
|
3367
|
-
)
|
|
3368
|
-
: changeFlowProposedTotal;
|
|
3369
|
-
|
|
3370
|
-
const displayChangeFlowProposedTotal = providerTotalsPreview
|
|
3371
|
-
? providerTotalsPreview.totalAmount
|
|
3372
|
-
: changeFlowProposedTotalResolved;
|
|
3373
|
-
const displayChangeFlowSubtotal = providerTotalsPreview
|
|
3374
|
-
? providerTotalsPreview.subtotalBeforeTax
|
|
3375
|
-
: isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3376
|
-
? Math.round(
|
|
3377
|
-
(originalReceipt.subtotal + (effectiveSubtotal - changeFlowBaselineEffectiveSubtotalFull)) * 100,
|
|
3378
|
-
) / 100
|
|
3379
|
-
: effectiveSubtotal;
|
|
3380
|
-
const displayChangeFlowTax = providerTotalsPreview
|
|
3381
|
-
? providerTotalsPreview.taxAmount
|
|
3382
|
-
: isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3383
|
-
? Math.round((originalReceipt.tax + (effectiveTax - changeFlowBaselineEffectiveTax)) * 100) / 100
|
|
3384
|
-
: effectiveTax;
|
|
3336
|
+
const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
|
|
3337
|
+
providerPreview: providerTotalsPreview,
|
|
3338
|
+
fromCart: {
|
|
3339
|
+
total: changeFlowNewBookingTotal,
|
|
3340
|
+
subtotal: effectiveSubtotal,
|
|
3341
|
+
tax: effectiveTax,
|
|
3342
|
+
},
|
|
3343
|
+
});
|
|
3344
|
+
const displayChangeFlowProposedTotal = displayedChangeAmounts.total;
|
|
3345
|
+
const displayChangeFlowSubtotal = displayedChangeAmounts.subtotal;
|
|
3346
|
+
const displayChangeFlowTax = displayedChangeAmounts.tax;
|
|
3385
3347
|
|
|
3386
3348
|
const changeFlowClientEstimateDue = originalReceipt
|
|
3387
|
-
? (
|
|
3388
|
-
|
|
3389
|
-
:
|
|
3349
|
+
? changeFlowBalanceVsOriginal({
|
|
3350
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
3351
|
+
originalReceiptTotal: originalReceipt.total,
|
|
3352
|
+
audience: isProviderDashboardChange ? 'provider' : 'customer',
|
|
3353
|
+
})
|
|
3390
3354
|
: totalPrice;
|
|
3391
3355
|
|
|
3392
|
-
/**
|
|
3393
|
-
* Amount owed for change flow: FE proposed total vs original receipt.
|
|
3394
|
-
* Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
|
|
3395
|
-
*/
|
|
3396
3356
|
const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
|
|
3397
|
-
const changeFlowAmountDue = isChangeFlow
|
|
3398
|
-
? (() => {
|
|
3399
|
-
const rounded = Math.round(changeFlowAmountDueRaw * 100) / 100;
|
|
3400
|
-
return Math.abs(rounded) < 0.01 ? 0 : rounded;
|
|
3401
|
-
})()
|
|
3402
|
-
: changeFlowAmountDueRaw;
|
|
3357
|
+
const changeFlowAmountDue = isChangeFlow ? normalizeNearZeroOwed(changeFlowAmountDueRaw) : changeFlowAmountDueRaw;
|
|
3403
3358
|
|
|
3404
|
-
useEffect(() => {
|
|
3405
|
-
if (!isChangeFlow || !isLocalhostChangeBookingDebug()) return;
|
|
3406
|
-
const receiptTicketLines =
|
|
3407
|
-
originalReceipt?.lineItems?.filter((l) => (l.type || '').toUpperCase() === 'TICKET') ?? [];
|
|
3408
|
-
const ticketFloors: Record<string, number> = {};
|
|
3409
|
-
changeFlowTicketBookedUnitPriceByCategory.forEach((v, k) => {
|
|
3410
|
-
ticketFloors[k] = v;
|
|
3411
|
-
});
|
|
3412
|
-
const tag = '[viavia change-booking]';
|
|
3413
|
-
// Plain console.log only (no groupCollapsed — those are easy to miss / count as “hidden” in DevTools).
|
|
3414
|
-
console.log(tag, 'itinerary gates', {
|
|
3415
|
-
outboundMatch: changeFlowOutboundMatchesOriginalSelection,
|
|
3416
|
-
returnMatch: changeFlowReturnMatchesOriginalSelection,
|
|
3417
|
-
sameItinerary: changeFlowSameItineraryAsOriginalBooking,
|
|
3418
|
-
});
|
|
3419
|
-
console.log(tag, 'ids & times', {
|
|
3420
|
-
initialAvailabilityId: initialValues?.availabilityId ?? null,
|
|
3421
|
-
selectedAvailabilityId: selectedAvailability?.availabilityId ?? null,
|
|
3422
|
-
initialDateTime: initialValues?.dateTime ?? null,
|
|
3423
|
-
selectedDateTime: selectedAvailability?.dateTime ?? null,
|
|
3424
|
-
initialOptionId: initialValues?.productOptionId ?? null,
|
|
3425
|
-
selectedOptionId: selectedAvailability?.productOptionId ?? null,
|
|
3426
|
-
initialReturnId: initialValues?.returnAvailabilityId ?? null,
|
|
3427
|
-
selectedReturnId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3428
|
-
initialReturnDateTime: initialValues?.returnDateTime ?? null,
|
|
3429
|
-
selectedReturnDateTime: selectedReturnOption?.dateTime ?? null,
|
|
3430
|
-
});
|
|
3431
|
-
console.log(tag, 'selection flags', {
|
|
3432
|
-
hasChangeSelection,
|
|
3433
|
-
hasEffectiveChangeSelection,
|
|
3434
|
-
changeFlowNeedsServerPrice,
|
|
3435
|
-
dateChanged: changeSelectionDetails.dateChanged,
|
|
3436
|
-
returnChanged: changeSelectionDetails.returnChanged,
|
|
3437
|
-
optionChanged: changeSelectionDetails.optionChanged,
|
|
3438
|
-
countsChanged: changeSelectionDetails.countsChanged,
|
|
3439
|
-
});
|
|
3440
|
-
console.log(tag, 'promo', {
|
|
3441
|
-
promoFromApi: promoDiscountAmount,
|
|
3442
|
-
lockedPromoFallback: lockedPromoFallbackAmount,
|
|
3443
|
-
effectivePromo: effectivePromoDiscountAmount,
|
|
3444
|
-
});
|
|
3445
|
-
console.log(tag, 'receipt anchor (same itinerary)', {
|
|
3446
|
-
baselineCatalogTotal: changeFlowBaselineTotalPrice,
|
|
3447
|
-
catalogDeltaVsBaseline: totalPrice - changeFlowBaselineTotalPrice,
|
|
3448
|
-
anchoredTotalRaw: changeFlowReceiptAnchoredCatalogTotalRaw,
|
|
3449
|
-
resolvedProposedTotal: changeFlowProposedTotalResolved,
|
|
3450
|
-
});
|
|
3451
|
-
console.log(tag, 'subtotals (live vs protected)', {
|
|
3452
|
-
orderSummarySubtotal: subtotal,
|
|
3453
|
-
currentTicketSubtotal,
|
|
3454
|
-
changeFlowProtectedTicketSubtotal,
|
|
3455
|
-
currentFeeSubtotal,
|
|
3456
|
-
changeFlowProtectedFeeSubtotal,
|
|
3457
|
-
returnPriceAdjustment,
|
|
3458
|
-
changeFlowProtectedReturnAdjustment,
|
|
3459
|
-
effectiveSubtotal,
|
|
3460
|
-
tax: effectiveTax,
|
|
3461
|
-
totalPrice,
|
|
3462
|
-
changeFlowProposedTotal,
|
|
3463
|
-
changeFlowProposedTotalResolved,
|
|
3464
|
-
originalReceiptTotal: originalReceipt?.total ?? null,
|
|
3465
|
-
amountDue: changeFlowAmountDue,
|
|
3466
|
-
});
|
|
3467
|
-
console.log(tag, 'ticket price floors from receipt (empty => live catalog for tickets)', ticketFloors);
|
|
3468
|
-
console.log(tag, 'receipt TICKET lines', receiptTicketLines);
|
|
3469
|
-
}, [
|
|
3470
|
-
isChangeFlow,
|
|
3471
|
-
changeFlowOutboundMatchesOriginalSelection,
|
|
3472
|
-
changeFlowReturnMatchesOriginalSelection,
|
|
3473
|
-
changeFlowSameItineraryAsOriginalBooking,
|
|
3474
|
-
initialValues?.availabilityId,
|
|
3475
|
-
initialValues?.dateTime,
|
|
3476
|
-
initialValues?.productOptionId,
|
|
3477
|
-
initialValues?.returnAvailabilityId,
|
|
3478
|
-
initialValues?.returnDateTime,
|
|
3479
|
-
selectedAvailability?.availabilityId,
|
|
3480
|
-
selectedAvailability?.dateTime,
|
|
3481
|
-
selectedAvailability?.productOptionId,
|
|
3482
|
-
selectedReturnOption?.returnAvailabilityId,
|
|
3483
|
-
selectedReturnOption?.dateTime,
|
|
3484
|
-
hasChangeSelection,
|
|
3485
|
-
hasEffectiveChangeSelection,
|
|
3486
|
-
changeFlowNeedsServerPrice,
|
|
3487
|
-
changeSelectionDetails.dateChanged,
|
|
3488
|
-
changeSelectionDetails.returnChanged,
|
|
3489
|
-
changeSelectionDetails.optionChanged,
|
|
3490
|
-
changeSelectionDetails.countsChanged,
|
|
3491
|
-
subtotal,
|
|
3492
|
-
currentTicketSubtotal,
|
|
3493
|
-
changeFlowProtectedTicketSubtotal,
|
|
3494
|
-
currentFeeSubtotal,
|
|
3495
|
-
changeFlowProtectedFeeSubtotal,
|
|
3496
|
-
returnPriceAdjustment,
|
|
3497
|
-
changeFlowProtectedReturnAdjustment,
|
|
3498
|
-
effectiveSubtotal,
|
|
3499
|
-
effectivePromoDiscountAmount,
|
|
3500
|
-
effectiveTax,
|
|
3501
|
-
totalPrice,
|
|
3502
|
-
changeFlowProposedTotal,
|
|
3503
|
-
originalReceipt?.total,
|
|
3504
|
-
originalReceipt?.lineItems,
|
|
3505
|
-
changeFlowAmountDue,
|
|
3506
|
-
changeFlowTicketBookedUnitPriceByCategory,
|
|
3507
|
-
changeFlowProposedTotalResolved,
|
|
3508
|
-
changeFlowBaselineTotalPrice,
|
|
3509
|
-
changeFlowReceiptAnchoredCatalogTotalRaw,
|
|
3510
|
-
promoDiscountAmount,
|
|
3511
|
-
lockedPromoFallbackAmount,
|
|
3512
|
-
]);
|
|
3513
3359
|
|
|
3514
3360
|
const changeCheckoutButtonLabel = (() => {
|
|
3515
3361
|
if (!isChangeFlow) return undefined;
|
|
@@ -3611,7 +3457,7 @@ export function BookingFlow({
|
|
|
3611
3457
|
dateChanged: changeSelectionDetails.dateChanged,
|
|
3612
3458
|
ticketsChanged: changeSelectionDetails.ticketsChanged,
|
|
3613
3459
|
hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
|
|
3614
|
-
selectionTotal: originalReceipt ?
|
|
3460
|
+
selectionTotal: originalReceipt ? changeFlowNewBookingTotal : totalPrice,
|
|
3615
3461
|
selectionCurrency: currency,
|
|
3616
3462
|
};
|
|
3617
3463
|
}, [
|
|
@@ -3625,7 +3471,7 @@ export function BookingFlow({
|
|
|
3625
3471
|
totalPrice,
|
|
3626
3472
|
currency,
|
|
3627
3473
|
originalReceipt,
|
|
3628
|
-
|
|
3474
|
+
changeFlowNewBookingTotal,
|
|
3629
3475
|
]);
|
|
3630
3476
|
|
|
3631
3477
|
useEffect(() => {
|
|
@@ -3668,7 +3514,7 @@ export function BookingFlow({
|
|
|
3668
3514
|
? displayChangeFlowTax
|
|
3669
3515
|
: effectiveTax
|
|
3670
3516
|
: 0,
|
|
3671
|
-
total: isChangeFlow && originalReceipt ?
|
|
3517
|
+
total: isChangeFlow && originalReceipt ? changeFlowNewBookingTotal : totalPrice,
|
|
3672
3518
|
currency,
|
|
3673
3519
|
});
|
|
3674
3520
|
}, [
|
|
@@ -3677,7 +3523,7 @@ export function BookingFlow({
|
|
|
3677
3523
|
totalQuantity,
|
|
3678
3524
|
effectiveSubtotal,
|
|
3679
3525
|
effectiveTax,
|
|
3680
|
-
|
|
3526
|
+
changeFlowNewBookingTotal,
|
|
3681
3527
|
displayChangeFlowSubtotal,
|
|
3682
3528
|
displayChangeFlowTax,
|
|
3683
3529
|
currency,
|
|
@@ -3739,7 +3585,7 @@ export function BookingFlow({
|
|
|
3739
3585
|
newPassengerCounts: bookingItems,
|
|
3740
3586
|
// Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
|
|
3741
3587
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3742
|
-
clientProposedTotal:
|
|
3588
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
3743
3589
|
capacitySeatCredit: {
|
|
3744
3590
|
enabled: true,
|
|
3745
3591
|
previousPassengerCount: changeFlowInitialTicketCount,
|
|
@@ -3791,7 +3637,7 @@ export function BookingFlow({
|
|
|
3791
3637
|
quantities,
|
|
3792
3638
|
addOnSelections,
|
|
3793
3639
|
changeFlowInitialTicketCount,
|
|
3794
|
-
|
|
3640
|
+
changeFlowNewBookingTotal,
|
|
3795
3641
|
totalPrice,
|
|
3796
3642
|
currency,
|
|
3797
3643
|
activeOptions,
|
|
@@ -3944,12 +3790,11 @@ export function BookingFlow({
|
|
|
3944
3790
|
|
|
3945
3791
|
const preferBooked =
|
|
3946
3792
|
isChangeFlow && initialReturnIdForSelect
|
|
3947
|
-
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect
|
|
3793
|
+
? sorted.find((opt) => opt.returnAvailabilityId === initialReturnIdForSelect)
|
|
3948
3794
|
: undefined;
|
|
3949
3795
|
const preferByDateTime =
|
|
3950
3796
|
isChangeFlow && initialReturnDtForSelect && !preferBooked
|
|
3951
3797
|
? sorted.find((opt) => {
|
|
3952
|
-
if (opt.vacancies <= 0) return false;
|
|
3953
3798
|
try {
|
|
3954
3799
|
return (
|
|
3955
3800
|
parseAvailabilityDateTime(opt.dateTime).getTime() ===
|
|
@@ -4416,7 +4261,7 @@ export function BookingFlow({
|
|
|
4416
4261
|
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4417
4262
|
newPassengerCounts: bookingItems,
|
|
4418
4263
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
4419
|
-
clientProposedTotal:
|
|
4264
|
+
clientProposedTotal: changeFlowNewBookingTotal,
|
|
4420
4265
|
});
|
|
4421
4266
|
const canProceed = quote.canProceed !== false;
|
|
4422
4267
|
const quoteCurrency = (quote.currency || currency) as Currency;
|
|
@@ -4437,7 +4282,11 @@ export function BookingFlow({
|
|
|
4437
4282
|
if (!canProceed) {
|
|
4438
4283
|
throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
|
|
4439
4284
|
}
|
|
4440
|
-
const feChangeDue =
|
|
4285
|
+
const feChangeDue = changeFlowBalanceVsOriginal({
|
|
4286
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4287
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4288
|
+
audience: 'customer',
|
|
4289
|
+
});
|
|
4441
4290
|
const serverAmountDue =
|
|
4442
4291
|
quote.amountDueCents != null
|
|
4443
4292
|
? Math.max(0, quote.amountDueCents / 100)
|
|
@@ -4547,20 +4396,24 @@ export function BookingFlow({
|
|
|
4547
4396
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
4548
4397
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
4549
4398
|
const amountDueForCheckout = isCustomerSelfServeChange
|
|
4550
|
-
?
|
|
4399
|
+
? changeFlowBalanceVsOriginal({
|
|
4400
|
+
newTotal: changeFlowNewBookingTotal,
|
|
4401
|
+
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4402
|
+
audience: 'customer',
|
|
4403
|
+
})
|
|
4551
4404
|
: totalPrice;
|
|
4552
4405
|
const lines = [
|
|
4553
|
-
...
|
|
4406
|
+
...ticketLineItemsForChangeFlowDisplay.map((line) => ({
|
|
4554
4407
|
label: line.category,
|
|
4555
4408
|
amount: line.itemTotal,
|
|
4556
4409
|
type: 'TICKET' as const,
|
|
4557
4410
|
quantity: line.qty,
|
|
4558
4411
|
})),
|
|
4559
|
-
...(
|
|
4412
|
+
...(checkoutReturnLineAmount !== 0
|
|
4560
4413
|
? [
|
|
4561
4414
|
{
|
|
4562
4415
|
label: `${t('booking.returnOption') || 'Return option'} (${totalQuantity} ${totalQuantity === 1 ? (t('booking.person') || 'person') : (t('booking.people') || 'people')})`,
|
|
4563
|
-
amount:
|
|
4416
|
+
amount: checkoutReturnLineAmount,
|
|
4564
4417
|
type: 'RETURN_OPTION' as const,
|
|
4565
4418
|
quantity: totalQuantity,
|
|
4566
4419
|
},
|
|
@@ -4758,7 +4611,7 @@ export function BookingFlow({
|
|
|
4758
4611
|
availabilityProductOptionId,
|
|
4759
4612
|
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4760
4613
|
clientSecret: paymentIntent.clientSecret ?? '',
|
|
4761
|
-
ticketLinesForModal:
|
|
4614
|
+
ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4762
4615
|
const rate = pricing.find((r) => r.category === line.category);
|
|
4763
4616
|
const breakdown = getPriceBreakdown(
|
|
4764
4617
|
line.category,
|
|
@@ -4769,7 +4622,7 @@ export function BookingFlow({
|
|
|
4769
4622
|
return { line, breakdown };
|
|
4770
4623
|
}),
|
|
4771
4624
|
feeLineItems: feeLineItemsWithAddOns,
|
|
4772
|
-
returnPriceAdjustment,
|
|
4625
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4773
4626
|
cancellationPolicyFee,
|
|
4774
4627
|
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4775
4628
|
subtotal: effectiveSubtotal,
|
|
@@ -4785,7 +4638,7 @@ export function BookingFlow({
|
|
|
4785
4638
|
return;
|
|
4786
4639
|
}
|
|
4787
4640
|
|
|
4788
|
-
const ticketLinesForModal: CheckoutModalLineItem[] =
|
|
4641
|
+
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4789
4642
|
const rate = pricing.find((r) => r.category === line.category);
|
|
4790
4643
|
const breakdown = getPriceBreakdown(
|
|
4791
4644
|
line.category,
|
|
@@ -4821,7 +4674,7 @@ export function BookingFlow({
|
|
|
4821
4674
|
: undefined,
|
|
4822
4675
|
ticketLines: ticketLinesForModal,
|
|
4823
4676
|
feeLineItems: feeLineItemsWithAddOns,
|
|
4824
|
-
returnPriceAdjustment,
|
|
4677
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4825
4678
|
cancellationPolicyFee,
|
|
4826
4679
|
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4827
4680
|
subtotal: effectiveSubtotal,
|
|
@@ -5107,6 +4960,7 @@ export function BookingFlow({
|
|
|
5107
4960
|
availabilitiesByDate={availabilitiesByDate}
|
|
5108
4961
|
selectedDate={selectedDate}
|
|
5109
4962
|
syncVisibleWeekToSelectedDate={isChangeFlow}
|
|
4963
|
+
selectableSoldOutDate={changeFlowOriginalDate}
|
|
5110
4964
|
isLoading={loadingAvailabilities || isFetchingMoreAvailabilities}
|
|
5111
4965
|
onDateSelect={(date) => {
|
|
5112
4966
|
setSelectedDate(date);
|
|
@@ -5129,7 +4983,11 @@ export function BookingFlow({
|
|
|
5129
4983
|
currency={currency}
|
|
5130
4984
|
showCapacity={isAdmin}
|
|
5131
4985
|
extraDiscountPercent={calendarDiscountPercent}
|
|
5132
|
-
|
|
4986
|
+
capDiscountBadgesToBookingDate={
|
|
4987
|
+
isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0
|
|
4988
|
+
? changeFlowOriginalDate
|
|
4989
|
+
: null
|
|
4990
|
+
}
|
|
5133
4991
|
/>
|
|
5134
4992
|
</div>
|
|
5135
4993
|
</div>
|
|
@@ -5271,6 +5129,7 @@ export function BookingFlow({
|
|
|
5271
5129
|
|
|
5272
5130
|
{/* Total and Checkout — shared PriceSummary component */}
|
|
5273
5131
|
{selectedAvailability && (
|
|
5132
|
+
<>
|
|
5274
5133
|
<CheckoutForm
|
|
5275
5134
|
priceSummaryLines={checkoutPriceSummaryLines}
|
|
5276
5135
|
totalPrice={changeFlowAmountDue}
|
|
@@ -5498,6 +5357,7 @@ export function BookingFlow({
|
|
|
5498
5357
|
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
5499
5358
|
}
|
|
5500
5359
|
/>
|
|
5360
|
+
</>
|
|
5501
5361
|
)}
|
|
5502
5362
|
</div>
|
|
5503
5363
|
</>
|