@ticketboothapp/booking 1.2.56 → 1.2.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/booking/BookingFlow.tsx +1289 -195
- package/src/components/booking/Calendar.module.css +6 -1
- package/src/components/booking/ChangeBookingDialog.tsx +41 -178
- package/src/components/booking/CheckoutForm.tsx +29 -4
- package/src/components/booking/DefaultTermsContent.tsx +178 -0
- package/src/components/booking/PriceBreakdown.tsx +92 -27
- package/src/components/booking/PriceSummary.tsx +77 -4
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +83 -27
- package/src/components/booking/TermsAcceptance.tsx +2 -1
- package/src/components/booking/booking-flow-ui.ts +42 -0
- package/src/components/booking/booking-flow.css +32 -0
- package/src/components/booking/change-booking-compare.module.css +97 -0
- package/src/components/booking/change-booking-compare.tsx +228 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +53 -0
- package/src/index.ts +19 -0
- package/src/lib/booking/pricing.ts +27 -19
- package/src/lib/booking-api.ts +7 -0
- package/src/runtime/types.ts +2 -1
|
@@ -76,8 +76,19 @@ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
|
|
|
76
76
|
import { MANAGE_BOOKING_FROM_CHANGE_PAYMENT, MANAGE_BOOKING_QUERY_FROM } from '../../lib/manage-booking-post-checkout';
|
|
77
77
|
import { useBookingHost } from '../../runtime';
|
|
78
78
|
import type { BookingFlowUiOptions } from './booking-flow-ui';
|
|
79
|
+
import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
|
|
79
80
|
import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
|
|
80
81
|
|
|
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
|
+
|
|
81
92
|
/** Live selection snapshot for change-booking compare UI (parent dialog). */
|
|
82
93
|
export interface ChangeFlowSelectionPreview {
|
|
83
94
|
tourName: string;
|
|
@@ -134,6 +145,148 @@ function extractTrailingQty(label: string): { baseLabel: string; qty: number } {
|
|
|
134
145
|
return { baseLabel, qty };
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
function normalizeLineLabelForCompare(label: string): string {
|
|
149
|
+
return label
|
|
150
|
+
.toLowerCase()
|
|
151
|
+
.replace(/\([^)]*\)/g, '')
|
|
152
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
153
|
+
.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
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
|
+
|
|
137
290
|
function deriveAddOnSelectionsFromReceiptLines(
|
|
138
291
|
addOns: AddOn[],
|
|
139
292
|
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
@@ -308,6 +461,11 @@ interface BookingFlowProps {
|
|
|
308
461
|
availabilityPricingProfileId?: string | null;
|
|
309
462
|
/** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
|
|
310
463
|
availabilityCancellationPolicyProfileId?: string | null;
|
|
464
|
+
/**
|
|
465
|
+
* Provider dashboard: with `mode="change"`, submit via this callback instead of customer self-serve
|
|
466
|
+
* quote and payment (`quoteChangeBooking`, Stripe).
|
|
467
|
+
*/
|
|
468
|
+
onChangeBooking?: (data: ProviderDashboardChangeBookingPayload) => Promise<void>;
|
|
311
469
|
}
|
|
312
470
|
|
|
313
471
|
function parseAvailabilityDateTime(value: string): Date {
|
|
@@ -602,7 +760,18 @@ export function BookingFlow({
|
|
|
602
760
|
partnerPortalBooking = false,
|
|
603
761
|
availabilityPricingProfileId,
|
|
604
762
|
availabilityCancellationPolicyProfileId,
|
|
763
|
+
onChangeBooking,
|
|
605
764
|
}: BookingFlowProps) {
|
|
765
|
+
const isManualOverrideEligibleLine = (line: { editable: boolean; type?: string; label?: string }): boolean => {
|
|
766
|
+
if (!line.editable) return false;
|
|
767
|
+
const type = (line.type ?? '').toUpperCase();
|
|
768
|
+
const label = (line.label ?? '').toLowerCase();
|
|
769
|
+
const isPromoLikeType =
|
|
770
|
+
type.includes('PROMO') || type.includes('DISCOUNT') || type.includes('VOUCHER') || type.includes('GIFT');
|
|
771
|
+
const isPromoLikeLabel =
|
|
772
|
+
label.includes('promo') || label.includes('discount') || label.includes('voucher') || label.includes('gift');
|
|
773
|
+
return !(isPromoLikeType || isPromoLikeLabel);
|
|
774
|
+
};
|
|
606
775
|
const { env, strings: defaultStrings, analytics, catalog } = useBookingHost();
|
|
607
776
|
const { t } = useTranslations();
|
|
608
777
|
const { locale } = useLocale();
|
|
@@ -638,6 +807,8 @@ export function BookingFlow({
|
|
|
638
807
|
changeWindowHoursBefore?: number | null;
|
|
639
808
|
} | null>(null);
|
|
640
809
|
const cancellationPolicyRef = useRef<HTMLDivElement>(null);
|
|
810
|
+
/** Dedupe parent updates from `onChangeFlowSelectionPreview` when serialized preview is unchanged. */
|
|
811
|
+
const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
|
|
641
812
|
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
642
813
|
const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
|
|
643
814
|
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
@@ -739,6 +910,8 @@ export function BookingFlow({
|
|
|
739
910
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
740
911
|
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
741
912
|
const isChangeFlow = mode === 'change';
|
|
913
|
+
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
914
|
+
const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
|
|
742
915
|
|
|
743
916
|
useEffect(() => {
|
|
744
917
|
setPartnerAttributionConfirmed(false);
|
|
@@ -748,12 +921,14 @@ export function BookingFlow({
|
|
|
748
921
|
* user picks a different return time — baseline is the first auto-selected return for this outbound.
|
|
749
922
|
*/
|
|
750
923
|
const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
924
|
+
/** Any change flow (self-serve or provider): promo from booking is fixed — show read-only, never add new. */
|
|
925
|
+
const lockedPromoCode =
|
|
926
|
+
isChangeFlow && initialValues?.promoCode?.trim()
|
|
927
|
+
? initialValues.promoCode.trim().toUpperCase()
|
|
928
|
+
: null;
|
|
929
|
+
/** Public self-serve only: cannot reduce tickets below original counts. */
|
|
755
930
|
const changeBookingMinimumQuantities = useMemo(() => {
|
|
756
|
-
if (!
|
|
931
|
+
if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
|
|
757
932
|
const m: Record<string, number> = {};
|
|
758
933
|
for (const item of initialValues.bookingItems) {
|
|
759
934
|
const key = item.category?.trim();
|
|
@@ -761,7 +936,7 @@ export function BookingFlow({
|
|
|
761
936
|
m[key] = Math.max(0, Number(item.count) || 0);
|
|
762
937
|
}
|
|
763
938
|
return m;
|
|
764
|
-
}, [
|
|
939
|
+
}, [isCustomerSelfServeChange, initialValues?.bookingItems]);
|
|
765
940
|
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
766
941
|
reservationReference: string;
|
|
767
942
|
reservationExpiration?: string;
|
|
@@ -1671,27 +1846,27 @@ export function BookingFlow({
|
|
|
1671
1846
|
|
|
1672
1847
|
const initialAddOnMinQtyByKey = useMemo(() => {
|
|
1673
1848
|
const map = new Map<string, number>();
|
|
1674
|
-
if (!
|
|
1849
|
+
if (!isCustomerSelfServeChange) return map;
|
|
1675
1850
|
for (const sel of initialAddOnBaselineSelections) {
|
|
1676
1851
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
1677
1852
|
map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1678
1853
|
}
|
|
1679
1854
|
return map;
|
|
1680
|
-
}, [
|
|
1855
|
+
}, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
|
|
1681
1856
|
|
|
1682
1857
|
const initialAddOnMinTotalByAddOnId = useMemo(() => {
|
|
1683
1858
|
const map = new Map<string, number>();
|
|
1684
|
-
if (!
|
|
1859
|
+
if (!isCustomerSelfServeChange) return map;
|
|
1685
1860
|
for (const sel of initialAddOnBaselineSelections) {
|
|
1686
1861
|
const addOnId = sel.addOnId.trim();
|
|
1687
1862
|
map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1688
1863
|
}
|
|
1689
1864
|
return map;
|
|
1690
|
-
}, [
|
|
1865
|
+
}, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
|
|
1691
1866
|
|
|
1692
1867
|
const applyChangeFlowAddOnFloor = useCallback(
|
|
1693
1868
|
(nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => {
|
|
1694
|
-
if (!
|
|
1869
|
+
if (!isCustomerSelfServeChange || initialAddOnMinQtyByKey.size === 0) return nextSelections;
|
|
1695
1870
|
const qtyByKey = new Map<string, number>();
|
|
1696
1871
|
for (const sel of nextSelections) {
|
|
1697
1872
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
@@ -1718,7 +1893,7 @@ export function BookingFlow({
|
|
|
1718
1893
|
return { addOnId, variantId, quantity: qty };
|
|
1719
1894
|
});
|
|
1720
1895
|
},
|
|
1721
|
-
[
|
|
1896
|
+
[isCustomerSelfServeChange, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
|
|
1722
1897
|
);
|
|
1723
1898
|
|
|
1724
1899
|
const updateAddOnSelections = useCallback(
|
|
@@ -1748,7 +1923,55 @@ export function BookingFlow({
|
|
|
1748
1923
|
}
|
|
1749
1924
|
return Math.max(...product.pickupLocations.map(loc => loc.pickupTimeOffsetMinutes ?? 0));
|
|
1750
1925
|
}, [product.pickupLocations]);
|
|
1926
|
+
const changeFlowInitialTicketCountForSeatCredit = useMemo(() => {
|
|
1927
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return 0;
|
|
1928
|
+
return initialValues.bookingItems.reduce(
|
|
1929
|
+
(sum, item) => sum + Math.max(0, Number(item.count) || 0),
|
|
1930
|
+
0,
|
|
1931
|
+
);
|
|
1932
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
1751
1933
|
|
|
1934
|
+
const changeFlowSeatCreditForOutboundAvailability = useCallback(
|
|
1935
|
+
(availability: Availability): number => {
|
|
1936
|
+
if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1937
|
+
const initialAvailabilityId = initialValues?.availabilityId?.trim();
|
|
1938
|
+
const availabilityId = availability.availabilityId?.trim();
|
|
1939
|
+
if (initialAvailabilityId && availabilityId) {
|
|
1940
|
+
return initialAvailabilityId === availabilityId ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1941
|
+
}
|
|
1942
|
+
if (!initialValues?.dateTime) return 0;
|
|
1943
|
+
const selectedMs = parseAvailabilityDateTime(availability.dateTime).getTime();
|
|
1944
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
1945
|
+
if (selectedMs !== initialMs) return 0;
|
|
1946
|
+
const selectedOptionId = availability.productOptionId?.trim() || null;
|
|
1947
|
+
const initialOptionId = initialValues.productOptionId?.trim() || null;
|
|
1948
|
+
if (!selectedOptionId || !initialOptionId) return changeFlowInitialTicketCountForSeatCredit;
|
|
1949
|
+
return selectedOptionId === initialOptionId ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1950
|
+
},
|
|
1951
|
+
[
|
|
1952
|
+
isChangeFlow,
|
|
1953
|
+
changeFlowInitialTicketCountForSeatCredit,
|
|
1954
|
+
initialValues?.availabilityId,
|
|
1955
|
+
initialValues?.dateTime,
|
|
1956
|
+
initialValues?.productOptionId,
|
|
1957
|
+
],
|
|
1958
|
+
);
|
|
1959
|
+
const changeFlowSeatCreditForReturnAvailabilityId = useCallback(
|
|
1960
|
+
(returnAvailabilityId: string | null | undefined): number => {
|
|
1961
|
+
if (!isChangeFlow || changeFlowInitialTicketCountForSeatCredit <= 0) return 0;
|
|
1962
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
1963
|
+
const selectedReturnAvailabilityId = returnAvailabilityId?.trim() || null;
|
|
1964
|
+
if (!selectedReturnAvailabilityId) {
|
|
1965
|
+
return initialReturnAvailabilityId == null ? changeFlowInitialTicketCountForSeatCredit : 0;
|
|
1966
|
+
}
|
|
1967
|
+
if (!initialReturnAvailabilityId) return 0;
|
|
1968
|
+
return selectedReturnAvailabilityId === initialReturnAvailabilityId
|
|
1969
|
+
? changeFlowInitialTicketCountForSeatCredit
|
|
1970
|
+
: 0;
|
|
1971
|
+
},
|
|
1972
|
+
[isChangeFlow, changeFlowInitialTicketCountForSeatCredit, initialValues?.returnAvailabilityId],
|
|
1973
|
+
);
|
|
1974
|
+
|
|
1752
1975
|
// Calculate pickup times based on availability times + pickup location offset
|
|
1753
1976
|
interface PickupTimeInfo extends Availability {
|
|
1754
1977
|
pickupTime: string;
|
|
@@ -1771,6 +1994,8 @@ export function BookingFlow({
|
|
|
1771
1994
|
return timesForSelectedDate.map(avail => {
|
|
1772
1995
|
// Parse the dateTime (which should already be in company timezone from backend)
|
|
1773
1996
|
const availabilityTime = parseISO(avail.dateTime);
|
|
1997
|
+
const vacancyCredit = changeFlowSeatCreditForOutboundAvailability(avail);
|
|
1998
|
+
const adjustedVacancies = Math.max(0, avail.vacancies ?? 0) + vacancyCredit;
|
|
1774
1999
|
|
|
1775
2000
|
// Only apply offset if it's set and > 0 and location is selected
|
|
1776
2001
|
const pickupTime = (offsetMinutes > 0 && selectedPickupLocation)
|
|
@@ -1791,13 +2016,22 @@ export function BookingFlow({
|
|
|
1791
2016
|
|
|
1792
2017
|
return {
|
|
1793
2018
|
...avail,
|
|
2019
|
+
vacancies: adjustedVacancies,
|
|
1794
2020
|
pickupTime: pickupTime.toISOString(),
|
|
1795
2021
|
displayTime,
|
|
1796
2022
|
originalTime,
|
|
1797
2023
|
displayTimeRange,
|
|
1798
2024
|
};
|
|
1799
2025
|
});
|
|
1800
|
-
}, [
|
|
2026
|
+
}, [
|
|
2027
|
+
selectedDate,
|
|
2028
|
+
selectedPickupLocation,
|
|
2029
|
+
timesForSelectedDate,
|
|
2030
|
+
pickupLocationSkipped,
|
|
2031
|
+
maxTimeOffsetMinutes,
|
|
2032
|
+
companyTimezone,
|
|
2033
|
+
changeFlowSeatCreditForOutboundAvailability,
|
|
2034
|
+
]);
|
|
1801
2035
|
|
|
1802
2036
|
// Check if any pickup time has "most popular" tag (memoized for performance)
|
|
1803
2037
|
const hasAnyMostPopular = useMemo(() => {
|
|
@@ -2042,7 +2276,7 @@ export function BookingFlow({
|
|
|
2042
2276
|
[pricingConfig?.fees]
|
|
2043
2277
|
);
|
|
2044
2278
|
|
|
2045
|
-
const
|
|
2279
|
+
const changeFlowTicketBookedUnitPriceByCategory = useMemo(() => {
|
|
2046
2280
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return new Map<string, number>();
|
|
2047
2281
|
const amountByCategory = new Map<string, number>();
|
|
2048
2282
|
const qtyByCategory = new Map<string, number>();
|
|
@@ -2083,26 +2317,141 @@ export function BookingFlow({
|
|
|
2083
2317
|
if (totalAmount <= 0 || totalQty <= 0) return null;
|
|
2084
2318
|
return totalAmount / totalQty;
|
|
2085
2319
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2320
|
+
const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
|
|
2321
|
+
const feeUnitByLabel = new Map<string, number>();
|
|
2322
|
+
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
|
|
2323
|
+
const fallbackBookedQty =
|
|
2324
|
+
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
2325
|
+
for (const line of originalReceipt.lineItems) {
|
|
2326
|
+
const type = (line.type || '').trim().toUpperCase();
|
|
2327
|
+
if (!line.label || type === 'TICKET' || type === 'RETURN_OPTION' || type === 'TAX') continue;
|
|
2328
|
+
if (type.includes('PROMO') || type.includes('VOUCHER') || type.includes('GIFT')) continue;
|
|
2329
|
+
const amount = Number(line.amount ?? 0);
|
|
2330
|
+
const qtyRaw = Number(line.quantity ?? 0);
|
|
2331
|
+
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
2332
|
+
if (!Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
|
|
2333
|
+
const key = normalizeLineLabelForCompare(line.label);
|
|
2334
|
+
if (!key) continue;
|
|
2335
|
+
feeUnitByLabel.set(key, amount / qty);
|
|
2336
|
+
}
|
|
2337
|
+
return feeUnitByLabel;
|
|
2338
|
+
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2339
|
+
|
|
2340
|
+
const changeFlowInitialTicketQtyByCategory = useMemo(() => {
|
|
2341
|
+
const qtyByCategory = new Map<string, number>();
|
|
2342
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
|
|
2343
|
+
for (const item of initialValues.bookingItems) {
|
|
2344
|
+
const category = item.category?.trim().toUpperCase();
|
|
2345
|
+
if (!category) continue;
|
|
2346
|
+
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2347
|
+
}
|
|
2348
|
+
return qtyByCategory;
|
|
2349
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
2350
|
+
const changeFlowInitialTicketCount = useMemo(() => {
|
|
2351
|
+
let sum = 0;
|
|
2352
|
+
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2353
|
+
return sum;
|
|
2354
|
+
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2355
|
+
const changeFlowOutboundMatchesOriginalSelection = useMemo(() => {
|
|
2356
|
+
if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
|
|
2357
|
+
const initialAvailabilityId = initialValues.availabilityId?.trim();
|
|
2358
|
+
const selectedAvailabilityId = selectedAvailability.availabilityId?.trim();
|
|
2359
|
+
const idsMatch =
|
|
2360
|
+
Boolean(initialAvailabilityId && selectedAvailabilityId) &&
|
|
2361
|
+
initialAvailabilityId === selectedAvailabilityId;
|
|
2362
|
+
if (idsMatch) return true;
|
|
2363
|
+
// Same wall time + option after API/list refreshes can carry a new availability row id; still "same" for price floors.
|
|
2364
|
+
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2365
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2366
|
+
if (selectedMs !== initialMs) return false;
|
|
2367
|
+
const selectedOptionId = selectedAvailability.productOptionId?.trim() || null;
|
|
2368
|
+
const initialOptionId = initialValues.productOptionId?.trim() || null;
|
|
2369
|
+
return !selectedOptionId || !initialOptionId || selectedOptionId === initialOptionId;
|
|
2370
|
+
}, [
|
|
2371
|
+
isChangeFlow,
|
|
2372
|
+
selectedAvailability,
|
|
2373
|
+
initialValues?.availabilityId,
|
|
2374
|
+
initialValues?.dateTime,
|
|
2375
|
+
initialValues?.productOptionId,
|
|
2376
|
+
]);
|
|
2377
|
+
const changeFlowReturnMatchesOriginalSelection = useMemo(() => {
|
|
2378
|
+
if (!isChangeFlow) return false;
|
|
2379
|
+
const initialReturnAvailabilityId = initialValues?.returnAvailabilityId?.trim() || null;
|
|
2380
|
+
const selectedReturnAvailabilityId = selectedReturnOption?.returnAvailabilityId?.trim() || null;
|
|
2381
|
+
const initialReturnDt = initialValues?.returnDateTime?.trim() || null;
|
|
2382
|
+
const selectedReturnDt = selectedReturnOption?.dateTime?.trim() || null;
|
|
2383
|
+
|
|
2384
|
+
if (!selectedReturnAvailabilityId) {
|
|
2385
|
+
return initialReturnAvailabilityId == null;
|
|
2386
|
+
}
|
|
2387
|
+
if (initialReturnAvailabilityId && selectedReturnAvailabilityId) {
|
|
2388
|
+
if (initialReturnAvailabilityId === selectedReturnAvailabilityId) return true;
|
|
2389
|
+
if (initialReturnDt && selectedReturnDt) {
|
|
2390
|
+
return (
|
|
2391
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2392
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
return false;
|
|
2396
|
+
}
|
|
2397
|
+
// Bookings often store returnDateTime without returnAvailabilityId; still the same return if wall times match.
|
|
2398
|
+
if (!initialReturnAvailabilityId && initialReturnDt && selectedReturnDt) {
|
|
2399
|
+
return (
|
|
2400
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() ===
|
|
2401
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime()
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
return false;
|
|
2405
|
+
}, [
|
|
2406
|
+
isChangeFlow,
|
|
2407
|
+
initialValues?.returnAvailabilityId,
|
|
2408
|
+
initialValues?.returnDateTime,
|
|
2409
|
+
selectedReturnOption?.returnAvailabilityId,
|
|
2410
|
+
selectedReturnOption?.dateTime,
|
|
2411
|
+
]);
|
|
2412
|
+
/** Same outbound + return as original booking: incremental seats use live pricing; receipt floors apply only after itinerary changes. */
|
|
2413
|
+
const changeFlowSameItineraryAsOriginalBooking = useMemo(
|
|
2414
|
+
() =>
|
|
2415
|
+
isChangeFlow &&
|
|
2416
|
+
changeFlowOutboundMatchesOriginalSelection &&
|
|
2417
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2418
|
+
[isChangeFlow, changeFlowOutboundMatchesOriginalSelection, changeFlowReturnMatchesOriginalSelection],
|
|
2419
|
+
);
|
|
2086
2420
|
|
|
2087
2421
|
const returnOptionsWithFloor = useMemo(() => {
|
|
2088
2422
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
2089
|
-
if (!isChangeFlow
|
|
2423
|
+
if (!isChangeFlow && changeFlowReturnUnitFloorPerPerson == null) return options;
|
|
2090
2424
|
return options.map((opt) => {
|
|
2425
|
+
const vacancyCredit = changeFlowSeatCreditForReturnAvailabilityId(opt.returnAvailabilityId);
|
|
2426
|
+
const adjustedVacancies = Math.max(0, opt.vacancies ?? 0) + vacancyCredit;
|
|
2091
2427
|
const rawPerPerson = opt.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2092
|
-
const
|
|
2093
|
-
|
|
2428
|
+
const applyReturnFloor =
|
|
2429
|
+
changeFlowReturnUnitFloorPerPerson != null && (!isChangeFlow || !changeFlowSameItineraryAsOriginalBooking);
|
|
2430
|
+
const flooredPerPerson = applyReturnFloor
|
|
2431
|
+
? Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson!)
|
|
2432
|
+
: rawPerPerson;
|
|
2433
|
+
if (flooredPerPerson === rawPerPerson && adjustedVacancies === (opt.vacancies ?? 0)) return opt;
|
|
2094
2434
|
return {
|
|
2095
2435
|
...opt,
|
|
2436
|
+
vacancies: adjustedVacancies,
|
|
2096
2437
|
priceAdjustmentByCurrency: {
|
|
2097
2438
|
...(opt.priceAdjustmentByCurrency ?? {}),
|
|
2098
2439
|
[currency]: flooredPerPerson,
|
|
2099
2440
|
},
|
|
2100
2441
|
};
|
|
2101
2442
|
});
|
|
2102
|
-
}, [
|
|
2443
|
+
}, [
|
|
2444
|
+
selectedAvailability?.returnOptions,
|
|
2445
|
+
isChangeFlow,
|
|
2446
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
2447
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2448
|
+
currency,
|
|
2449
|
+
changeFlowSeatCreditForReturnAvailabilityId,
|
|
2450
|
+
]);
|
|
2103
2451
|
|
|
2104
2452
|
const selectedReturnOptionWithFloor = useMemo(() => {
|
|
2105
2453
|
if (!selectedReturnOption || !isChangeFlow || changeFlowReturnUnitFloorPerPerson == null) return selectedReturnOption;
|
|
2454
|
+
if (changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption;
|
|
2106
2455
|
const rawPerPerson = selectedReturnOption.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2107
2456
|
const flooredPerPerson = Math.max(rawPerPerson, changeFlowReturnUnitFloorPerPerson);
|
|
2108
2457
|
if (flooredPerPerson === rawPerPerson) return selectedReturnOption;
|
|
@@ -2113,7 +2462,30 @@ export function BookingFlow({
|
|
|
2113
2462
|
[currency]: flooredPerPerson,
|
|
2114
2463
|
},
|
|
2115
2464
|
};
|
|
2116
|
-
}, [
|
|
2465
|
+
}, [
|
|
2466
|
+
selectedReturnOption,
|
|
2467
|
+
isChangeFlow,
|
|
2468
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
2469
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2470
|
+
currency,
|
|
2471
|
+
]);
|
|
2472
|
+
|
|
2473
|
+
const returnOptionForOrderSummary = useMemo(() => {
|
|
2474
|
+
if (isChangeFlow && changeFlowSameItineraryAsOriginalBooking) return selectedReturnOption ?? null;
|
|
2475
|
+
return selectedReturnOptionWithFloor;
|
|
2476
|
+
}, [isChangeFlow, changeFlowSameItineraryAsOriginalBooking, selectedReturnOption, selectedReturnOptionWithFloor]);
|
|
2477
|
+
const effectiveSelectedPickupVacancies = useMemo(() => {
|
|
2478
|
+
if (!selectedAvailability) return 0;
|
|
2479
|
+
const seatCredit = changeFlowSeatCreditForOutboundAvailability(selectedAvailability);
|
|
2480
|
+
return Math.max(0, selectedAvailability.vacancies ?? 0) + seatCredit;
|
|
2481
|
+
}, [selectedAvailability, changeFlowSeatCreditForOutboundAvailability]);
|
|
2482
|
+
const effectiveSelectedReturnVacancies = useMemo(() => {
|
|
2483
|
+
if (!selectedReturnOption) return null;
|
|
2484
|
+
const seatCredit = changeFlowSeatCreditForReturnAvailabilityId(
|
|
2485
|
+
selectedReturnOption.returnAvailabilityId
|
|
2486
|
+
);
|
|
2487
|
+
return Math.max(0, selectedReturnOption.vacancies ?? 0) + seatCredit;
|
|
2488
|
+
}, [selectedReturnOption, changeFlowSeatCreditForReturnAvailabilityId]);
|
|
2117
2489
|
|
|
2118
2490
|
// Ticket prices: use breakdown final price so booking flow total matches the price breakdown. All conversion in mid-layer.
|
|
2119
2491
|
const pricing = useMemo(() => {
|
|
@@ -2156,13 +2528,11 @@ export function BookingFlow({
|
|
|
2156
2528
|
const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
2157
2529
|
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
2158
2530
|
const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
|
|
2159
|
-
const floorUnitPrice = changeFlowTicketPriceFloorByCategory.get(rate.category.toUpperCase());
|
|
2160
|
-
const price = floorUnitPrice != null ? Math.max(built.price, floorUnitPrice) : built.price;
|
|
2161
2531
|
return {
|
|
2162
2532
|
category: rate.category,
|
|
2163
2533
|
rateId: rate.rateId || rate.category,
|
|
2164
2534
|
available: rate.available,
|
|
2165
|
-
price,
|
|
2535
|
+
price: built.price,
|
|
2166
2536
|
priceCAD: built.priceCAD,
|
|
2167
2537
|
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2168
2538
|
appliedAdjustments: built.appliedAdjustments,
|
|
@@ -2172,19 +2542,17 @@ export function BookingFlow({
|
|
|
2172
2542
|
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
2173
2543
|
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
2174
2544
|
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
2175
|
-
const floorUnitPrice = changeFlowTicketPriceFloorByCategory.get(p.category.toUpperCase());
|
|
2176
|
-
const price = floorUnitPrice != null ? Math.max(built.price, floorUnitPrice) : built.price;
|
|
2177
2545
|
return {
|
|
2178
2546
|
category: p.category,
|
|
2179
2547
|
rateId: p.category,
|
|
2180
2548
|
available: selectedAvailability.vacancies,
|
|
2181
|
-
price,
|
|
2549
|
+
price: built.price,
|
|
2182
2550
|
priceCAD: built.priceCAD,
|
|
2183
2551
|
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2184
2552
|
appliedAdjustments: built.appliedAdjustments,
|
|
2185
2553
|
};
|
|
2186
2554
|
}) || [];
|
|
2187
|
-
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView
|
|
2555
|
+
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
|
|
2188
2556
|
|
|
2189
2557
|
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2190
2558
|
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
@@ -2211,22 +2579,97 @@ export function BookingFlow({
|
|
|
2211
2579
|
computeOrderSummary(
|
|
2212
2580
|
quantities,
|
|
2213
2581
|
pricing,
|
|
2214
|
-
|
|
2582
|
+
returnOptionForOrderSummary,
|
|
2215
2583
|
pricingConfig ?? null,
|
|
2216
2584
|
currency,
|
|
2217
2585
|
hasFees,
|
|
2218
2586
|
cancellationPolicyId
|
|
2219
2587
|
),
|
|
2220
|
-
[quantities, pricing,
|
|
2588
|
+
[quantities, pricing, returnOptionForOrderSummary, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2221
2589
|
);
|
|
2590
|
+
|
|
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
|
+
|
|
2222
2626
|
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2627
|
+
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2628
|
+
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2629
|
+
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2630
|
+
0,
|
|
2631
|
+
);
|
|
2632
|
+
if (!changeFlowSameItineraryAsOriginalBooking) return currentTicketSubtotal;
|
|
2633
|
+
return ticketLineItems.reduce((sum, line) => {
|
|
2634
|
+
const category = line.category?.trim().toUpperCase();
|
|
2635
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2636
|
+
if (!category || qty <= 0) return sum;
|
|
2637
|
+
const bookedUnitPrice = changeFlowTicketBookedUnitPriceByCategory.get(category);
|
|
2638
|
+
if (bookedUnitPrice == null) return sum + line.itemTotal;
|
|
2639
|
+
const baselineQty = changeFlowInitialTicketQtyByCategory.get(category) ?? 0;
|
|
2640
|
+
const protectedQty = Math.min(qty, baselineQty);
|
|
2641
|
+
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
2642
|
+
const liveUnit = qty > 0 ? Math.max(0, Number(line.itemTotal) || 0) / qty : 0;
|
|
2643
|
+
return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnit;
|
|
2644
|
+
}, 0);
|
|
2645
|
+
}, [
|
|
2646
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2647
|
+
ticketLineItems,
|
|
2648
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2649
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2650
|
+
]);
|
|
2223
2651
|
/** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
|
|
2224
2652
|
const effectivePartySizeCap = useMemo(() => {
|
|
2225
2653
|
if (!selectedAvailability) return 0;
|
|
2226
|
-
const
|
|
2654
|
+
const outboundSeatCredit =
|
|
2655
|
+
changeFlowOutboundMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2656
|
+
? changeFlowInitialTicketCount
|
|
2657
|
+
: 0;
|
|
2658
|
+
const outbound = Math.max(0, selectedAvailability.vacancies ?? 0) + outboundSeatCredit;
|
|
2227
2659
|
if (selectedReturnOption == null) return outbound;
|
|
2228
|
-
|
|
2229
|
-
|
|
2660
|
+
const returnSeatCredit =
|
|
2661
|
+
changeFlowReturnMatchesOriginalSelection && changeFlowInitialTicketCount > 0
|
|
2662
|
+
? changeFlowInitialTicketCount
|
|
2663
|
+
: 0;
|
|
2664
|
+
const returnCap = Math.max(0, selectedReturnOption.vacancies ?? 0) + returnSeatCredit;
|
|
2665
|
+
return Math.min(outbound, returnCap);
|
|
2666
|
+
}, [
|
|
2667
|
+
selectedAvailability,
|
|
2668
|
+
selectedReturnOption,
|
|
2669
|
+
changeFlowInitialTicketCount,
|
|
2670
|
+
changeFlowOutboundMatchesOriginalSelection,
|
|
2671
|
+
changeFlowReturnMatchesOriginalSelection,
|
|
2672
|
+
]);
|
|
2230
2673
|
|
|
2231
2674
|
const selectedCancellationPolicy = pricingConfig?.cancellationPolicies?.find((p) => p.id === cancellationPolicyId);
|
|
2232
2675
|
/** Label for display when policy may be forced by promo (not in pricingConfig list). */
|
|
@@ -2283,7 +2726,64 @@ export function BookingFlow({
|
|
|
2283
2726
|
}, [addOnSelections, addOns]);
|
|
2284
2727
|
|
|
2285
2728
|
// Effective subtotal includes add-ons (for promo discount and total)
|
|
2286
|
-
const
|
|
2729
|
+
const currentTicketSubtotal = useMemo(
|
|
2730
|
+
() => ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0),
|
|
2731
|
+
[ticketLineItems],
|
|
2732
|
+
);
|
|
2733
|
+
const changeFlowInitialTotalTicketQty = changeFlowInitialTicketCount;
|
|
2734
|
+
const currentFeeSubtotal = useMemo(
|
|
2735
|
+
() => feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0),
|
|
2736
|
+
[feeLineItems],
|
|
2737
|
+
);
|
|
2738
|
+
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2739
|
+
if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2740
|
+
const initialParty = changeFlowInitialTicketCount;
|
|
2741
|
+
const protectedP = Math.min(initialParty, totalQuantity);
|
|
2742
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
2743
|
+
return feeLineItems.reduce((sum, line) => {
|
|
2744
|
+
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2745
|
+
const bookedUnitPrice = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : null;
|
|
2746
|
+
if (bookedUnitPrice == null) return sum + line.totalAmount;
|
|
2747
|
+
const liveTotal = Math.max(0, Number(line.totalAmount) || 0);
|
|
2748
|
+
const livePer = totalQuantity > 0 ? liveTotal / totalQuantity : 0;
|
|
2749
|
+
return sum + protectedP * bookedUnitPrice + incrementalP * livePer;
|
|
2750
|
+
}, 0);
|
|
2751
|
+
}, [
|
|
2752
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2753
|
+
totalQuantity,
|
|
2754
|
+
currentFeeSubtotal,
|
|
2755
|
+
feeLineItems,
|
|
2756
|
+
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2757
|
+
changeFlowInitialTicketCount,
|
|
2758
|
+
]);
|
|
2759
|
+
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2760
|
+
if (!changeFlowSameItineraryAsOriginalBooking || totalQuantity <= 0) return returnPriceAdjustment;
|
|
2761
|
+
if (changeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2762
|
+
const rawPerPerson = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
|
|
2763
|
+
const initialParty = changeFlowInitialTicketCount;
|
|
2764
|
+
const protectedP = Math.min(initialParty, totalQuantity);
|
|
2765
|
+
const incrementalP = Math.max(0, totalQuantity - protectedP);
|
|
2766
|
+
return protectedP * changeFlowReturnUnitFloorPerPerson + incrementalP * rawPerPerson;
|
|
2767
|
+
}, [
|
|
2768
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
2769
|
+
totalQuantity,
|
|
2770
|
+
returnPriceAdjustment,
|
|
2771
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
2772
|
+
changeFlowInitialTicketCount,
|
|
2773
|
+
selectedReturnOption,
|
|
2774
|
+
currency,
|
|
2775
|
+
]);
|
|
2776
|
+
const effectiveSubtotalBeforeAddOns =
|
|
2777
|
+
isChangeFlow && changeFlowSameItineraryAsOriginalBooking
|
|
2778
|
+
? subtotal -
|
|
2779
|
+
currentTicketSubtotal -
|
|
2780
|
+
currentFeeSubtotal -
|
|
2781
|
+
returnPriceAdjustment +
|
|
2782
|
+
changeFlowProtectedTicketSubtotal +
|
|
2783
|
+
changeFlowProtectedFeeSubtotal +
|
|
2784
|
+
changeFlowProtectedReturnAdjustment
|
|
2785
|
+
: subtotal;
|
|
2786
|
+
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
2287
2787
|
|
|
2288
2788
|
/** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
|
|
2289
2789
|
const quantitiesSignature = useMemo(
|
|
@@ -2315,6 +2815,30 @@ export function BookingFlow({
|
|
|
2315
2815
|
return [...feeLineItems, ...addOnLines];
|
|
2316
2816
|
}, [feeLineItems, addOnSelections, addOns]);
|
|
2317
2817
|
|
|
2818
|
+
const providerPricingUi = flowUi?.providerDashboardChangePricingUi;
|
|
2819
|
+
const providerQuotedLines = providerPricingUi?.quotedLines ?? [];
|
|
2820
|
+
const providerEditableLines = providerQuotedLines.filter((line) => isManualOverrideEligibleLine(line));
|
|
2821
|
+
const showProviderPricingInlineEditor =
|
|
2822
|
+
isProviderDashboardChange && isAdmin && isChangeFlow && (
|
|
2823
|
+
providerPricingUi?.loading ||
|
|
2824
|
+
providerPricingUi?.error != null ||
|
|
2825
|
+
providerQuotedLines.length > 0
|
|
2826
|
+
);
|
|
2827
|
+
const normalizeReceiptLabel = (value: string): string =>
|
|
2828
|
+
value
|
|
2829
|
+
.toLowerCase()
|
|
2830
|
+
.replace(/\([^)]*\)/g, '')
|
|
2831
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
2832
|
+
.trim();
|
|
2833
|
+
const providerEditableLineByNormalizedLabel = useMemo(() => {
|
|
2834
|
+
const m = new Map<string, (typeof providerEditableLines)[number]>();
|
|
2835
|
+
for (const line of providerEditableLines) {
|
|
2836
|
+
const key = normalizeReceiptLabel(line.label ?? '');
|
|
2837
|
+
if (key) m.set(key, line);
|
|
2838
|
+
}
|
|
2839
|
+
return m;
|
|
2840
|
+
}, [providerEditableLines]);
|
|
2841
|
+
|
|
2318
2842
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2319
2843
|
if (!selectedAvailability) return [];
|
|
2320
2844
|
return [
|
|
@@ -2328,6 +2852,13 @@ export function BookingFlow({
|
|
|
2328
2852
|
);
|
|
2329
2853
|
return {
|
|
2330
2854
|
kind: 'ticket',
|
|
2855
|
+
lineKey:
|
|
2856
|
+
showProviderPricingInlineEditor
|
|
2857
|
+
? providerEditableLineByNormalizedLabel.get(normalizeReceiptLabel(line.category))?.lineKey
|
|
2858
|
+
: undefined,
|
|
2859
|
+
editable:
|
|
2860
|
+
showProviderPricingInlineEditor &&
|
|
2861
|
+
providerEditableLineByNormalizedLabel.has(normalizeReceiptLabel(line.category)),
|
|
2331
2862
|
category: line.category,
|
|
2332
2863
|
qty: line.qty,
|
|
2333
2864
|
itemTotal: line.itemTotal,
|
|
@@ -2362,6 +2893,25 @@ export function BookingFlow({
|
|
|
2362
2893
|
fee.name.toLowerCase().includes('license'));
|
|
2363
2894
|
return {
|
|
2364
2895
|
kind: 'line' as const,
|
|
2896
|
+
lineKey:
|
|
2897
|
+
showProviderPricingInlineEditor
|
|
2898
|
+
? providerEditableLineByNormalizedLabel.get(
|
|
2899
|
+
normalizeReceiptLabel(
|
|
2900
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
2901
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2902
|
+
: fee.name
|
|
2903
|
+
)
|
|
2904
|
+
)?.lineKey
|
|
2905
|
+
: undefined,
|
|
2906
|
+
editable:
|
|
2907
|
+
showProviderPricingInlineEditor &&
|
|
2908
|
+
providerEditableLineByNormalizedLabel.has(
|
|
2909
|
+
normalizeReceiptLabel(
|
|
2910
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
2911
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2912
|
+
: fee.name
|
|
2913
|
+
)
|
|
2914
|
+
),
|
|
2365
2915
|
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2366
2916
|
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2367
2917
|
: fee.name,
|
|
@@ -2388,6 +2938,8 @@ export function BookingFlow({
|
|
|
2388
2938
|
effectiveCancellationPolicyLabel,
|
|
2389
2939
|
feeLineItemsWithAddOns,
|
|
2390
2940
|
feeLineItems,
|
|
2941
|
+
showProviderPricingInlineEditor,
|
|
2942
|
+
providerEditableLineByNormalizedLabel,
|
|
2391
2943
|
]);
|
|
2392
2944
|
|
|
2393
2945
|
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
@@ -2514,15 +3066,119 @@ export function BookingFlow({
|
|
|
2514
3066
|
? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
|
|
2515
3067
|
: taxOnSubtotal;
|
|
2516
3068
|
const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
|
|
3069
|
+
/** Cent-round for change quote only; matches server final rounding and avoids float noise in clientProposedTotal. */
|
|
2517
3070
|
const changeFlowProposedTotal = isChangeFlow && originalReceipt
|
|
2518
|
-
? reconcileChangeBookingProposedTotal(totalPrice, originalReceipt.total)
|
|
3071
|
+
? reconcileChangeBookingProposedTotal(Math.round(totalPrice * 100) / 100, originalReceipt.total)
|
|
2519
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
|
+
}, [
|
|
3121
|
+
isChangeFlow,
|
|
3122
|
+
changeFlowSameItineraryAsOriginalBooking,
|
|
3123
|
+
changeFlowBaselineEffectiveSubtotalFull,
|
|
3124
|
+
effectiveTax,
|
|
3125
|
+
isTaxIncludedInPrice,
|
|
3126
|
+
pricingConfig?.taxRate,
|
|
3127
|
+
effectivePromoDiscountAmount,
|
|
3128
|
+
isGiftCard,
|
|
3129
|
+
isVoucher,
|
|
3130
|
+
]);
|
|
3131
|
+
|
|
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
|
+
]);
|
|
2520
3156
|
const changeSelectionDetails = useMemo(() => {
|
|
2521
3157
|
if (!isChangeFlow || !initialValues) {
|
|
2522
|
-
return {
|
|
3158
|
+
return {
|
|
3159
|
+
hasChangesFromInitial: true,
|
|
3160
|
+
hasOperationalChangesFromInitial: true,
|
|
3161
|
+
dateChanged: false,
|
|
3162
|
+
ticketsChanged: false,
|
|
3163
|
+
optionChanged: false,
|
|
3164
|
+
pickupChanged: false,
|
|
3165
|
+
countsChanged: false,
|
|
3166
|
+
addOnsChanged: false,
|
|
3167
|
+
returnChanged: false,
|
|
3168
|
+
};
|
|
2523
3169
|
}
|
|
2524
3170
|
if (!selectedAvailability) {
|
|
2525
|
-
return {
|
|
3171
|
+
return {
|
|
3172
|
+
hasChangesFromInitial: false,
|
|
3173
|
+
hasOperationalChangesFromInitial: false,
|
|
3174
|
+
dateChanged: false,
|
|
3175
|
+
ticketsChanged: false,
|
|
3176
|
+
optionChanged: false,
|
|
3177
|
+
pickupChanged: false,
|
|
3178
|
+
countsChanged: false,
|
|
3179
|
+
addOnsChanged: false,
|
|
3180
|
+
returnChanged: false,
|
|
3181
|
+
};
|
|
2526
3182
|
}
|
|
2527
3183
|
const initialMs = initialValues.dateTime ? parseAvailabilityDateTime(initialValues.dateTime).getTime() : null;
|
|
2528
3184
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
@@ -2534,7 +3190,13 @@ export function BookingFlow({
|
|
|
2534
3190
|
const optionChanged = Boolean(
|
|
2535
3191
|
initialOpt && selectedOpt && initialOpt !== selectedOpt
|
|
2536
3192
|
);
|
|
2537
|
-
const
|
|
3193
|
+
const normalizePickupId = (value: string | null | undefined) => {
|
|
3194
|
+
const trimmed = value?.trim();
|
|
3195
|
+
return trimmed ? trimmed : null;
|
|
3196
|
+
};
|
|
3197
|
+
const pickupChanged =
|
|
3198
|
+
normalizePickupId(initialValues.pickupLocationId ?? null) !==
|
|
3199
|
+
normalizePickupId(pickupLocationId ?? null);
|
|
2538
3200
|
const normalizeCounts = (items: Array<{ category: string; count: number }> | null | undefined) => {
|
|
2539
3201
|
const map = new Map<string, number>();
|
|
2540
3202
|
for (const item of items ?? []) {
|
|
@@ -2580,7 +3242,16 @@ export function BookingFlow({
|
|
|
2580
3242
|
let returnChanged = false;
|
|
2581
3243
|
if (hasReturnOptions && selectedReturnOption) {
|
|
2582
3244
|
if (initialReturnId && selectedReturnId) {
|
|
2583
|
-
|
|
3245
|
+
if (initialReturnId === selectedReturnId) {
|
|
3246
|
+
returnChanged = false;
|
|
3247
|
+
} else if (initialReturnDt && selectedReturnDt) {
|
|
3248
|
+
// Some refreshes can rotate return availability IDs for the same departure time.
|
|
3249
|
+
returnChanged =
|
|
3250
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() !==
|
|
3251
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime();
|
|
3252
|
+
} else {
|
|
3253
|
+
returnChanged = true;
|
|
3254
|
+
}
|
|
2584
3255
|
} else if (initialReturnDt && selectedReturnDt) {
|
|
2585
3256
|
returnChanged =
|
|
2586
3257
|
parseAvailabilityDateTime(initialReturnDt).getTime() !==
|
|
@@ -2597,9 +3268,22 @@ export function BookingFlow({
|
|
|
2597
3268
|
countsChanged ||
|
|
2598
3269
|
addOnsChanged ||
|
|
2599
3270
|
returnChanged,
|
|
3271
|
+
// Authoritative for "real user change" gating in provider dashboard:
|
|
3272
|
+
// ignore option-id noise and only consider user-visible booking deltas.
|
|
3273
|
+
hasOperationalChangesFromInitial:
|
|
3274
|
+
dateChanged ||
|
|
3275
|
+
pickupChanged ||
|
|
3276
|
+
countsChanged ||
|
|
3277
|
+
addOnsChanged ||
|
|
3278
|
+
returnChanged,
|
|
2600
3279
|
dateChanged,
|
|
2601
3280
|
// Tickets line corresponds to "option + ticket counts"; add-ons and pickup changes affect itinerary but not the ticket label.
|
|
2602
3281
|
ticketsChanged: Boolean(optionChanged || countsChanged),
|
|
3282
|
+
optionChanged,
|
|
3283
|
+
pickupChanged,
|
|
3284
|
+
countsChanged,
|
|
3285
|
+
addOnsChanged,
|
|
3286
|
+
returnChanged,
|
|
2603
3287
|
};
|
|
2604
3288
|
}, [
|
|
2605
3289
|
isChangeFlow,
|
|
@@ -2612,38 +3296,232 @@ export function BookingFlow({
|
|
|
2612
3296
|
addOnSelections,
|
|
2613
3297
|
initialAddOnMinQtyByKey,
|
|
2614
3298
|
]);
|
|
2615
|
-
const hasChangeSelection =
|
|
3299
|
+
const hasChangeSelection =
|
|
3300
|
+
isProviderDashboardChange
|
|
3301
|
+
? changeSelectionDetails.hasOperationalChangesFromInitial
|
|
3302
|
+
: changeSelectionDetails.hasChangesFromInitial;
|
|
2616
3303
|
|
|
2617
3304
|
const changeFlowNeedsServerPrice =
|
|
2618
|
-
|
|
3305
|
+
isCustomerSelfServeChange &&
|
|
2619
3306
|
hasChangeSelection &&
|
|
2620
3307
|
!!initialValues?.bookingReference?.trim() &&
|
|
2621
3308
|
!!lastName.trim();
|
|
2622
3309
|
|
|
2623
|
-
const isChangeQuoteBlocked =
|
|
2624
|
-
const requiresReturnInChangeFlow =
|
|
3310
|
+
const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
|
|
3311
|
+
const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
|
|
2625
3312
|
const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
|
|
2626
3313
|
|
|
2627
3314
|
const changeFlowSubmitDisabled =
|
|
2628
3315
|
isChangeFlow &&
|
|
2629
3316
|
(missingRequiredReturnSelection ||
|
|
2630
|
-
(
|
|
3317
|
+
(isCustomerSelfServeChange &&
|
|
3318
|
+
changeFlowNeedsServerPrice &&
|
|
3319
|
+
(changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError))));
|
|
3320
|
+
|
|
3321
|
+
const providerTotalsPreview =
|
|
3322
|
+
showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
|
|
3323
|
+
? providerPricingUi.totalsPreview
|
|
3324
|
+
: null;
|
|
3325
|
+
const providerHasEditedLineOverrides =
|
|
3326
|
+
isProviderDashboardChange &&
|
|
3327
|
+
providerQuotedLines.some((line) => {
|
|
3328
|
+
if (!isManualOverrideEligibleLine(line)) return false;
|
|
3329
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
3330
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
3331
|
+
if (!Number.isFinite(parsed)) return false;
|
|
3332
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3333
|
+
return Math.abs(rounded - line.amount) > 0.0001;
|
|
3334
|
+
});
|
|
3335
|
+
const providerHasAdditionalAdjustments =
|
|
3336
|
+
isProviderDashboardChange &&
|
|
3337
|
+
(providerPricingUi?.additionalAdjustments ?? []).some((adj) => {
|
|
3338
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3339
|
+
const hasAmount = Number.isFinite(parsed) && parsed > 0;
|
|
3340
|
+
const currentAmount = hasAmount ? (Math.round(parsed * 100) / 100).toFixed(2) : '';
|
|
3341
|
+
const originalAmount = adj.originalAmountInput ?? '';
|
|
3342
|
+
const currentLabel = (adj.label ?? '').trim();
|
|
3343
|
+
const originalLabel = (adj.originalLabel ?? '').trim();
|
|
3344
|
+
const currentMode = adj.mode;
|
|
3345
|
+
const originalMode = adj.originalMode;
|
|
3346
|
+
if (!originalMode) return hasAmount || currentLabel.length > 0;
|
|
3347
|
+
return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
|
|
3348
|
+
});
|
|
3349
|
+
const hasEffectiveChangeSelection =
|
|
3350
|
+
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
3351
|
+
|
|
3352
|
+
/**
|
|
3353
|
+
* Same itinerary: amount owed must be catalog delta from the *initial* party size, anchored to what
|
|
3354
|
+
* was actually paid (`originalReceipt`). Otherwise `totalPrice − receipt.total` includes a bogus gap
|
|
3355
|
+
* whenever list pricing ≠ historical receipt (e.g. ~$703 + real ticket delta).
|
|
3356
|
+
*/
|
|
3357
|
+
const changeFlowReceiptAnchoredCatalogTotalRaw =
|
|
3358
|
+
isChangeFlow && originalReceipt && changeFlowSameItineraryAsOriginalBooking
|
|
3359
|
+
? originalReceipt.total + (totalPrice - changeFlowBaselineTotalPrice)
|
|
3360
|
+
: totalPrice;
|
|
3361
|
+
|
|
3362
|
+
const changeFlowProposedTotalResolved =
|
|
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;
|
|
2631
3385
|
|
|
2632
3386
|
const changeFlowClientEstimateDue = originalReceipt
|
|
2633
|
-
?
|
|
3387
|
+
? (isProviderDashboardChange
|
|
3388
|
+
? displayChangeFlowProposedTotal - originalReceipt.total
|
|
3389
|
+
: Math.max(displayChangeFlowProposedTotal - originalReceipt.total, 0))
|
|
2634
3390
|
: totalPrice;
|
|
2635
3391
|
|
|
2636
3392
|
/**
|
|
2637
|
-
* Amount owed for change flow:
|
|
3393
|
+
* Amount owed for change flow: FE proposed total vs original receipt.
|
|
2638
3394
|
* Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
|
|
2639
3395
|
*/
|
|
2640
3396
|
const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
|
|
2641
|
-
const changeFlowAmountDue =
|
|
2642
|
-
|
|
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;
|
|
3403
|
+
|
|
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
|
+
]);
|
|
2643
3513
|
|
|
2644
3514
|
const changeCheckoutButtonLabel = (() => {
|
|
2645
3515
|
if (!isChangeFlow) return undefined;
|
|
2646
|
-
if (!
|
|
3516
|
+
if (!hasEffectiveChangeSelection) return undefined;
|
|
3517
|
+
if (isProviderDashboardChange) {
|
|
3518
|
+
const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
|
|
3519
|
+
return est > 0
|
|
3520
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3521
|
+
: est < 0
|
|
3522
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3523
|
+
: 'Change booking (no charge)';
|
|
3524
|
+
}
|
|
2647
3525
|
if (changeFlowNeedsServerPrice) {
|
|
2648
3526
|
if (changeQuoteLoading) {
|
|
2649
3527
|
const tr = t('booking.updatingPrice');
|
|
@@ -2679,8 +3557,39 @@ export function BookingFlow({
|
|
|
2679
3557
|
const checkoutFormError =
|
|
2680
3558
|
(error || '') ||
|
|
2681
3559
|
(missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
|
|
2682
|
-
(isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
2683
|
-
(changeQuoteFetchError ?? '');
|
|
3560
|
+
(isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
3561
|
+
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
3562
|
+
|
|
3563
|
+
const providerPricingOverrides =
|
|
3564
|
+
isProviderDashboardChange && providerQuotedLines.length > 0
|
|
3565
|
+
? providerQuotedLines
|
|
3566
|
+
.filter((line) => isManualOverrideEligibleLine(line))
|
|
3567
|
+
.map((line) => {
|
|
3568
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
3569
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
3570
|
+
if (!Number.isFinite(parsed)) return null;
|
|
3571
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3572
|
+
return Math.abs(rounded - line.amount) > 0.0001
|
|
3573
|
+
? { lineKey: line.lineKey, amount: rounded, reason: 'Provider dashboard override' }
|
|
3574
|
+
: null;
|
|
3575
|
+
})
|
|
3576
|
+
.filter((v): v is { lineKey: string; amount: number; reason: string } => v != null)
|
|
3577
|
+
: [];
|
|
3578
|
+
const providerAdditionalAdjustments =
|
|
3579
|
+
isProviderDashboardChange
|
|
3580
|
+
? (providerPricingUi?.additionalAdjustments ?? [])
|
|
3581
|
+
.map((adj) => {
|
|
3582
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3583
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
3584
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3585
|
+
const signed = adj.mode === 'DISCOUNT' ? -rounded : rounded;
|
|
3586
|
+
return {
|
|
3587
|
+
label: adj.label?.trim() || (signed < 0 ? 'Provider discount' : 'Provider charge'),
|
|
3588
|
+
amount: signed,
|
|
3589
|
+
};
|
|
3590
|
+
})
|
|
3591
|
+
.filter((v): v is { label: string; amount: number } => v != null)
|
|
3592
|
+
: [];
|
|
2684
3593
|
|
|
2685
3594
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
2686
3595
|
if (!isChangeFlow) return null;
|
|
@@ -2702,7 +3611,7 @@ export function BookingFlow({
|
|
|
2702
3611
|
dateChanged: changeSelectionDetails.dateChanged,
|
|
2703
3612
|
ticketsChanged: changeSelectionDetails.ticketsChanged,
|
|
2704
3613
|
hasChangesFromInitial: changeSelectionDetails.hasChangesFromInitial,
|
|
2705
|
-
selectionTotal: totalPrice,
|
|
3614
|
+
selectionTotal: originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
|
|
2706
3615
|
selectionCurrency: currency,
|
|
2707
3616
|
};
|
|
2708
3617
|
}, [
|
|
@@ -2715,11 +3624,34 @@ export function BookingFlow({
|
|
|
2715
3624
|
changeSelectionDetails,
|
|
2716
3625
|
totalPrice,
|
|
2717
3626
|
currency,
|
|
3627
|
+
originalReceipt,
|
|
3628
|
+
changeFlowProposedTotalResolved,
|
|
2718
3629
|
]);
|
|
2719
3630
|
|
|
2720
3631
|
useEffect(() => {
|
|
2721
|
-
if (!isChangeFlow
|
|
2722
|
-
|
|
3632
|
+
if (!isChangeFlow) {
|
|
3633
|
+
lastChangeFlowPreviewKeyRef.current = null;
|
|
3634
|
+
return;
|
|
3635
|
+
}
|
|
3636
|
+
if (!onChangeFlowSelectionPreview) return;
|
|
3637
|
+
const next = changeFlowSelectionPreview;
|
|
3638
|
+
const key =
|
|
3639
|
+
next === null
|
|
3640
|
+
? 'null'
|
|
3641
|
+
: JSON.stringify({
|
|
3642
|
+
tourName: next.tourName,
|
|
3643
|
+
dateTime: next.dateTime,
|
|
3644
|
+
ticketsLine: next.ticketsLine,
|
|
3645
|
+
itinerarySteps: next.itinerarySteps,
|
|
3646
|
+
dateChanged: next.dateChanged,
|
|
3647
|
+
ticketsChanged: next.ticketsChanged,
|
|
3648
|
+
hasChangesFromInitial: next.hasChangesFromInitial,
|
|
3649
|
+
selectionTotal: next.selectionTotal,
|
|
3650
|
+
selectionCurrency: next.selectionCurrency,
|
|
3651
|
+
});
|
|
3652
|
+
if (key === lastChangeFlowPreviewKeyRef.current) return;
|
|
3653
|
+
lastChangeFlowPreviewKeyRef.current = key;
|
|
3654
|
+
onChangeFlowSelectionPreview(next);
|
|
2723
3655
|
}, [isChangeFlow, changeFlowSelectionPreview, onChangeFlowSelectionPreview]);
|
|
2724
3656
|
|
|
2725
3657
|
useEffect(() => {
|
|
@@ -2729,9 +3661,14 @@ export function BookingFlow({
|
|
|
2729
3661
|
return;
|
|
2730
3662
|
}
|
|
2731
3663
|
onPricePreviewChange({
|
|
2732
|
-
subtotal: effectiveSubtotal,
|
|
2733
|
-
tax:
|
|
2734
|
-
|
|
3664
|
+
subtotal: isChangeFlow && originalReceipt ? displayChangeFlowSubtotal : effectiveSubtotal,
|
|
3665
|
+
tax:
|
|
3666
|
+
!isTaxIncludedInPrice
|
|
3667
|
+
? isChangeFlow && originalReceipt
|
|
3668
|
+
? displayChangeFlowTax
|
|
3669
|
+
: effectiveTax
|
|
3670
|
+
: 0,
|
|
3671
|
+
total: isChangeFlow && originalReceipt ? changeFlowProposedTotalResolved : totalPrice,
|
|
2735
3672
|
currency,
|
|
2736
3673
|
});
|
|
2737
3674
|
}, [
|
|
@@ -2740,16 +3677,22 @@ export function BookingFlow({
|
|
|
2740
3677
|
totalQuantity,
|
|
2741
3678
|
effectiveSubtotal,
|
|
2742
3679
|
effectiveTax,
|
|
2743
|
-
|
|
3680
|
+
changeFlowProposedTotalResolved,
|
|
3681
|
+
displayChangeFlowSubtotal,
|
|
3682
|
+
displayChangeFlowTax,
|
|
2744
3683
|
currency,
|
|
2745
3684
|
isTaxIncludedInPrice,
|
|
3685
|
+
isChangeFlow,
|
|
3686
|
+
originalReceipt,
|
|
3687
|
+
totalPrice,
|
|
2746
3688
|
]);
|
|
2747
3689
|
|
|
2748
3690
|
/** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
|
|
2749
3691
|
useEffect(() => {
|
|
2750
|
-
if (!
|
|
3692
|
+
if (!isCustomerSelfServeChange) {
|
|
2751
3693
|
setChangeQuoteLoading(false);
|
|
2752
3694
|
setChangeQuoteFetchError(null);
|
|
3695
|
+
setLatestChangeQuote(null);
|
|
2753
3696
|
return;
|
|
2754
3697
|
}
|
|
2755
3698
|
|
|
@@ -2796,7 +3739,13 @@ export function BookingFlow({
|
|
|
2796
3739
|
newPassengerCounts: bookingItems,
|
|
2797
3740
|
// Omit when empty: backend treats [] as "clear all"; missing = preserve stored selections (BookingChangeIntentService).
|
|
2798
3741
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
2799
|
-
clientProposedTotal:
|
|
3742
|
+
clientProposedTotal: changeFlowProposedTotalResolved,
|
|
3743
|
+
capacitySeatCredit: {
|
|
3744
|
+
enabled: true,
|
|
3745
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
3746
|
+
previousAvailabilityId: initialValues.availabilityId ?? null,
|
|
3747
|
+
previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
|
|
3748
|
+
},
|
|
2800
3749
|
});
|
|
2801
3750
|
if (seq !== changeQuoteRequestSeq.current) return;
|
|
2802
3751
|
const canProceed = quote.canProceed !== false;
|
|
@@ -2829,7 +3778,7 @@ export function BookingFlow({
|
|
|
2829
3778
|
window.clearTimeout(timer);
|
|
2830
3779
|
};
|
|
2831
3780
|
}, [
|
|
2832
|
-
|
|
3781
|
+
isCustomerSelfServeChange,
|
|
2833
3782
|
hasChangeSelection,
|
|
2834
3783
|
selectedAvailability,
|
|
2835
3784
|
selectedAvailability?.dateTime,
|
|
@@ -2841,9 +3790,13 @@ export function BookingFlow({
|
|
|
2841
3790
|
selectedReturnOption?.returnAvailabilityId,
|
|
2842
3791
|
quantities,
|
|
2843
3792
|
addOnSelections,
|
|
3793
|
+
changeFlowInitialTicketCount,
|
|
3794
|
+
changeFlowProposedTotalResolved,
|
|
2844
3795
|
totalPrice,
|
|
2845
3796
|
currency,
|
|
2846
3797
|
activeOptions,
|
|
3798
|
+
initialValues?.availabilityId,
|
|
3799
|
+
initialValues?.returnAvailabilityId,
|
|
2847
3800
|
]);
|
|
2848
3801
|
|
|
2849
3802
|
// Auto-select product option when date is selected: most popular if set, otherwise first available.
|
|
@@ -3339,31 +4292,33 @@ export function BookingFlow({
|
|
|
3339
4292
|
setError('Removing return option in self-serve is not available. Please contact support.');
|
|
3340
4293
|
return;
|
|
3341
4294
|
}
|
|
3342
|
-
|
|
3343
|
-
// Validate email (required)
|
|
3344
|
-
if (!email) {
|
|
3345
|
-
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
3346
|
-
return;
|
|
3347
|
-
}
|
|
3348
|
-
|
|
3349
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
3350
|
-
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
3351
|
-
return;
|
|
3352
|
-
}
|
|
3353
4295
|
|
|
3354
|
-
|
|
3355
|
-
if (!
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
4296
|
+
const skipContactFields = isProviderDashboardChange && isChangeFlow;
|
|
4297
|
+
if (!skipContactFields) {
|
|
4298
|
+
// Validate email (required)
|
|
4299
|
+
if (!email) {
|
|
4300
|
+
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
4301
|
+
return;
|
|
4302
|
+
}
|
|
3359
4303
|
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
4304
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
4305
|
+
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
4306
|
+
return;
|
|
4307
|
+
}
|
|
4308
|
+
|
|
4309
|
+
// Validate first name (required)
|
|
4310
|
+
if (!firstName?.trim()) {
|
|
4311
|
+
setError(t('booking.enterFirstName') || 'Please enter your first name');
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
// Validate last name (required for manage booking lookup)
|
|
4316
|
+
if (!lastName?.trim()) {
|
|
4317
|
+
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
3364
4320
|
}
|
|
3365
|
-
|
|
3366
|
-
|
|
4321
|
+
|
|
3367
4322
|
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
3368
4323
|
if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
|
|
3369
4324
|
setError(t('booking.selectPickupLocation'));
|
|
@@ -3389,6 +4344,45 @@ export function BookingFlow({
|
|
|
3389
4344
|
return;
|
|
3390
4345
|
}
|
|
3391
4346
|
|
|
4347
|
+
if (onChangeBooking && isChangeFlow) {
|
|
4348
|
+
const pickupForChange = pickupLocationId
|
|
4349
|
+
? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
|
|
4350
|
+
: null;
|
|
4351
|
+
await onChangeBooking({
|
|
4352
|
+
productId: availabilityProductOptionId,
|
|
4353
|
+
dateTime: selectedAvailability.dateTime,
|
|
4354
|
+
bookingItems,
|
|
4355
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4356
|
+
pickupLocationId: pickupLocationId ?? null,
|
|
4357
|
+
travelerHotel: pickupForChange?.name ?? null,
|
|
4358
|
+
startTime: selectedAvailability.dateTime ?? null,
|
|
4359
|
+
passengerCount: null,
|
|
4360
|
+
childSafetySeatsCount: null,
|
|
4361
|
+
foodRestrictions: null,
|
|
4362
|
+
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
4363
|
+
cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
|
|
4364
|
+
promoCode: appliedPromoCode ?? null,
|
|
4365
|
+
newTotalAmount: displayChangeFlowProposedTotal,
|
|
4366
|
+
additionalHoursCount: null,
|
|
4367
|
+
pricingAdjustment:
|
|
4368
|
+
providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
|
|
4369
|
+
? {
|
|
4370
|
+
mode: 'MANUAL_LINES',
|
|
4371
|
+
lineOverrides: providerPricingOverrides,
|
|
4372
|
+
additionalAdjustments: providerAdditionalAdjustments,
|
|
4373
|
+
}
|
|
4374
|
+
: undefined,
|
|
4375
|
+
capacitySeatCredit: {
|
|
4376
|
+
enabled: true,
|
|
4377
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
4378
|
+
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
4379
|
+
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
4380
|
+
},
|
|
4381
|
+
});
|
|
4382
|
+
setLoading(false);
|
|
4383
|
+
return;
|
|
4384
|
+
}
|
|
4385
|
+
|
|
3392
4386
|
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
3393
4387
|
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
3394
4388
|
product.productId,
|
|
@@ -3406,7 +4400,7 @@ export function BookingFlow({
|
|
|
3406
4400
|
let changeIntentIdForCheckout: string | undefined;
|
|
3407
4401
|
let changeBookingReferenceForPaidFlow: string | undefined;
|
|
3408
4402
|
|
|
3409
|
-
if (
|
|
4403
|
+
if (isCustomerSelfServeChange) {
|
|
3410
4404
|
const changeBookingReference = initialValues?.bookingReference?.trim();
|
|
3411
4405
|
const changeLastName = lastName.trim();
|
|
3412
4406
|
if (!changeBookingReference || !changeLastName) {
|
|
@@ -3422,7 +4416,7 @@ export function BookingFlow({
|
|
|
3422
4416
|
newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3423
4417
|
newPassengerCounts: bookingItems,
|
|
3424
4418
|
...(addOnSelections.length > 0 ? { newAddOnSelections: addOnSelections } : {}),
|
|
3425
|
-
clientProposedTotal:
|
|
4419
|
+
clientProposedTotal: changeFlowProposedTotalResolved,
|
|
3426
4420
|
});
|
|
3427
4421
|
const canProceed = quote.canProceed !== false;
|
|
3428
4422
|
const quoteCurrency = (quote.currency || currency) as Currency;
|
|
@@ -3443,7 +4437,7 @@ export function BookingFlow({
|
|
|
3443
4437
|
if (!canProceed) {
|
|
3444
4438
|
throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
|
|
3445
4439
|
}
|
|
3446
|
-
const feChangeDue = Math.max(0,
|
|
4440
|
+
const feChangeDue = Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0));
|
|
3447
4441
|
const serverAmountDue =
|
|
3448
4442
|
quote.amountDueCents != null
|
|
3449
4443
|
? Math.max(0, quote.amountDueCents / 100)
|
|
@@ -3552,8 +4546,8 @@ export function BookingFlow({
|
|
|
3552
4546
|
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
3553
4547
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
3554
4548
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
3555
|
-
const amountDueForCheckout =
|
|
3556
|
-
? Math.max(0,
|
|
4549
|
+
const amountDueForCheckout = isCustomerSelfServeChange
|
|
4550
|
+
? Math.max(0, changeFlowProposedTotalResolved - (originalReceipt?.total ?? 0))
|
|
3557
4551
|
: totalPrice;
|
|
3558
4552
|
const lines = [
|
|
3559
4553
|
...ticketLineItems.map((line) => ({
|
|
@@ -3674,7 +4668,7 @@ export function BookingFlow({
|
|
|
3674
4668
|
return;
|
|
3675
4669
|
}
|
|
3676
4670
|
|
|
3677
|
-
const paymentIntent =
|
|
4671
|
+
const paymentIntent = isCustomerSelfServeChange
|
|
3678
4672
|
? await createChangeBookingPaymentIntent(
|
|
3679
4673
|
(() => {
|
|
3680
4674
|
const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
|
|
@@ -3811,7 +4805,7 @@ export function BookingFlow({
|
|
|
3811
4805
|
// Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
|
|
3812
4806
|
// /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
|
|
3813
4807
|
successUrlOverride:
|
|
3814
|
-
|
|
4808
|
+
isCustomerSelfServeChange && changeBookingReferenceForPaidFlow
|
|
3815
4809
|
? (() => {
|
|
3816
4810
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
3817
4811
|
const ref = encodeURIComponent(
|
|
@@ -3839,10 +4833,10 @@ export function BookingFlow({
|
|
|
3839
4833
|
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
3840
4834
|
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
3841
4835
|
changeTotals:
|
|
3842
|
-
|
|
4836
|
+
isCustomerSelfServeChange && originalReceipt
|
|
3843
4837
|
? {
|
|
3844
4838
|
previousTotal: originalReceipt.total,
|
|
3845
|
-
newTotal:
|
|
4839
|
+
newTotal: displayChangeFlowProposedTotal,
|
|
3846
4840
|
differenceTotal: amountDueForCheckout,
|
|
3847
4841
|
}
|
|
3848
4842
|
: undefined,
|
|
@@ -4135,7 +5129,7 @@ export function BookingFlow({
|
|
|
4135
5129
|
currency={currency}
|
|
4136
5130
|
showCapacity={isAdmin}
|
|
4137
5131
|
extraDiscountPercent={calendarDiscountPercent}
|
|
4138
|
-
capDiscountToSelectedDate={isChangeFlow &&
|
|
5132
|
+
capDiscountToSelectedDate={isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0}
|
|
4139
5133
|
/>
|
|
4140
5134
|
</div>
|
|
4141
5135
|
</div>
|
|
@@ -4248,11 +5242,9 @@ export function BookingFlow({
|
|
|
4248
5242
|
selectedVacancies={effectivePartySizeCap}
|
|
4249
5243
|
companyTimezone={companyTimezone}
|
|
4250
5244
|
pickupDateTime={selectedAvailability.dateTime}
|
|
4251
|
-
pickupVacancies={
|
|
5245
|
+
pickupVacancies={effectiveSelectedPickupVacancies}
|
|
4252
5246
|
returnDateTime={selectedReturnOption?.dateTime ?? null}
|
|
4253
|
-
returnVacancies={
|
|
4254
|
-
selectedReturnOption != null ? (selectedReturnOption.vacancies ?? 0) : null
|
|
4255
|
-
}
|
|
5247
|
+
returnVacancies={effectiveSelectedReturnVacancies}
|
|
4256
5248
|
resourceCount={selectedReturnOption ? null : (selectedAvailability.resourceCount ?? null)}
|
|
4257
5249
|
currency={currency}
|
|
4258
5250
|
locale={locale}
|
|
@@ -4261,7 +5253,7 @@ export function BookingFlow({
|
|
|
4261
5253
|
t={t}
|
|
4262
5254
|
onQuantityChange={handleQuantityChange}
|
|
4263
5255
|
minimumQuantities={changeBookingMinimumQuantities}
|
|
4264
|
-
ticketUnitFloorByCategory={isChangeFlow ?
|
|
5256
|
+
ticketUnitFloorByCategory={isChangeFlow ? changeFlowTicketBookedUnitPriceByCategory : undefined}
|
|
4265
5257
|
/>
|
|
4266
5258
|
)}
|
|
4267
5259
|
|
|
@@ -4273,48 +5265,71 @@ export function BookingFlow({
|
|
|
4273
5265
|
currency={currency}
|
|
4274
5266
|
locale={locale}
|
|
4275
5267
|
onSelectionsChange={updateAddOnSelections}
|
|
4276
|
-
minimumTotalByAddOnId={
|
|
5268
|
+
minimumTotalByAddOnId={isCustomerSelfServeChange ? initialAddOnMinTotalByAddOnId : undefined}
|
|
4277
5269
|
/>
|
|
4278
5270
|
)}
|
|
4279
5271
|
|
|
4280
5272
|
{/* Total and Checkout — shared PriceSummary component */}
|
|
4281
5273
|
{selectedAvailability && (
|
|
4282
5274
|
<CheckoutForm
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
5275
|
+
priceSummaryLines={checkoutPriceSummaryLines}
|
|
5276
|
+
totalPrice={changeFlowAmountDue}
|
|
5277
|
+
totalSummaryLabel={
|
|
5278
|
+
isChangeFlow
|
|
5279
|
+
? (t('booking.totalOwedForBookingChange') &&
|
|
5280
|
+
t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
|
|
5281
|
+
? t('booking.totalOwedForBookingChange')
|
|
5282
|
+
: 'Total owed for booking difference')
|
|
5283
|
+
: undefined
|
|
5284
|
+
}
|
|
5285
|
+
subtotal={
|
|
5286
|
+
isChangeFlow
|
|
5287
|
+
? displayChangeFlowSubtotal
|
|
5288
|
+
: (subtotal !== totalFromSummary || effectivePromoDiscountAmount > 0 || addOnTotal > 0
|
|
5289
|
+
? effectiveSubtotal
|
|
5290
|
+
: undefined)
|
|
5291
|
+
}
|
|
5292
|
+
taxAmount={
|
|
5293
|
+
!isTaxIncludedInPrice &&
|
|
5294
|
+
(isChangeFlow ? displayChangeFlowTax : (effectivePromoDiscountAmount > 0 ? effectiveTax : tax)) > 0
|
|
5295
|
+
? (isChangeFlow ? displayChangeFlowTax : (effectivePromoDiscountAmount > 0 ? effectiveTax : tax))
|
|
5296
|
+
: 0
|
|
5297
|
+
}
|
|
5298
|
+
taxRate={pricingConfig?.taxRate}
|
|
5299
|
+
currency={currency}
|
|
5300
|
+
locale={locale}
|
|
5301
|
+
t={t}
|
|
5302
|
+
extraBetweenTaxAndTotal={
|
|
5303
|
+
<>
|
|
5304
|
+
{showProviderPricingInlineEditor && providerPricingUi?.error ? (
|
|
5305
|
+
<div className="mt-2 text-sm text-red-700">{providerPricingUi.error}</div>
|
|
5306
|
+
) : null}
|
|
5307
|
+
{showProviderPricingInlineEditor &&
|
|
5308
|
+
providerPricingUi?.loading &&
|
|
5309
|
+
providerQuotedLines.length === 0 ? (
|
|
5310
|
+
<div className="mt-2 text-sm text-stone-500">Loading price lines...</div>
|
|
5311
|
+
) : null}
|
|
5312
|
+
{showProviderPricingInlineEditor &&
|
|
5313
|
+
providerPricingUi?.helperText &&
|
|
5314
|
+
!providerPricingUi.error ? (
|
|
5315
|
+
<div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
|
|
5316
|
+
) : null}
|
|
5317
|
+
{isChangeFlow && lockedPromoCode ? (
|
|
5318
|
+
<PromoCodeInput
|
|
5319
|
+
promoCodeInput={promoCodeInput}
|
|
5320
|
+
appliedPromoCode={appliedPromoCode}
|
|
5321
|
+
promoCodeError={promoCodeError}
|
|
5322
|
+
promoCodeValidating={promoCodeValidating}
|
|
5323
|
+
promoDiscountAmount={effectivePromoDiscountAmount}
|
|
5324
|
+
currency={currency}
|
|
5325
|
+
locale={locale}
|
|
5326
|
+
t={t}
|
|
5327
|
+
onInputChange={() => {}}
|
|
5328
|
+
onApply={() => {}}
|
|
5329
|
+
onRemove={() => {}}
|
|
5330
|
+
locked
|
|
5331
|
+
/>
|
|
5332
|
+
) : !isChangeFlow ? (
|
|
4318
5333
|
<PromoCodeInput
|
|
4319
5334
|
promoCodeInput={promoCodeInput}
|
|
4320
5335
|
appliedPromoCode={appliedPromoCode}
|
|
@@ -4339,71 +5354,150 @@ export function BookingFlow({
|
|
|
4339
5354
|
fetchedRangesRef.current = [];
|
|
4340
5355
|
}}
|
|
4341
5356
|
/>
|
|
4342
|
-
) : null
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
5357
|
+
) : null}
|
|
5358
|
+
</>
|
|
5359
|
+
}
|
|
5360
|
+
extraBeforeSubtotal={
|
|
5361
|
+
showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
|
|
5362
|
+
<div className="space-y-1">
|
|
5363
|
+
{providerPricingUi?.additionalAdjustments?.map((adj) => (
|
|
5364
|
+
<div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
|
|
5365
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
5366
|
+
<button
|
|
5367
|
+
type="button"
|
|
5368
|
+
className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
|
|
5369
|
+
onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
|
|
5370
|
+
>
|
|
5371
|
+
-
|
|
5372
|
+
</button>
|
|
5373
|
+
<input
|
|
5374
|
+
type="text"
|
|
5375
|
+
className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
|
|
5376
|
+
placeholder="Line description"
|
|
5377
|
+
value={adj.label}
|
|
5378
|
+
onChange={(e) =>
|
|
5379
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
|
|
5380
|
+
}
|
|
5381
|
+
/>
|
|
5382
|
+
</div>
|
|
5383
|
+
<div className="flex items-center gap-1">
|
|
5384
|
+
<select
|
|
5385
|
+
className="rounded border border-stone-300 px-1 py-0.5 text-xs"
|
|
5386
|
+
value={adj.mode}
|
|
5387
|
+
onChange={(e) =>
|
|
5388
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
5389
|
+
mode: e.target.value as 'DISCOUNT' | 'CHARGE',
|
|
5390
|
+
})
|
|
5391
|
+
}
|
|
5392
|
+
>
|
|
5393
|
+
<option value="DISCOUNT">-</option>
|
|
5394
|
+
<option value="CHARGE">+</option>
|
|
5395
|
+
</select>
|
|
5396
|
+
<input
|
|
5397
|
+
type="text"
|
|
5398
|
+
inputMode="decimal"
|
|
5399
|
+
className="h-6 w-24 rounded border border-stone-300 bg-white px-2 py-0.5 text-right text-sm font-medium leading-none text-stone-700"
|
|
5400
|
+
placeholder="0.00"
|
|
5401
|
+
value={adj.amountInput}
|
|
5402
|
+
onChange={(e) =>
|
|
5403
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
5404
|
+
amountInput: e.target.value,
|
|
5405
|
+
})
|
|
5406
|
+
}
|
|
5407
|
+
/>
|
|
5408
|
+
</div>
|
|
5409
|
+
</div>
|
|
5410
|
+
))}
|
|
5411
|
+
<button
|
|
5412
|
+
type="button"
|
|
5413
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
5414
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
5415
|
+
>
|
|
5416
|
+
+ add line item
|
|
5417
|
+
</button>
|
|
5418
|
+
</div>
|
|
5419
|
+
) : showProviderPricingInlineEditor ? (
|
|
5420
|
+
<button
|
|
5421
|
+
type="button"
|
|
5422
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
5423
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
5424
|
+
>
|
|
5425
|
+
+ add line item
|
|
5426
|
+
</button>
|
|
5427
|
+
) : undefined
|
|
5428
|
+
}
|
|
5429
|
+
firstName={firstName}
|
|
5430
|
+
lastName={lastName}
|
|
5431
|
+
email={email}
|
|
5432
|
+
onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
|
|
5433
|
+
onLastNameChange={(v) => { setLastName(v); setError(''); }}
|
|
5434
|
+
onEmailChange={(v) => { setEmail(v); setError(''); }}
|
|
5435
|
+
readOnlyContactFields={isChangeFlow}
|
|
5436
|
+
pickupLocations={
|
|
5437
|
+
selectedDate && product.pickupLocations && product.pickupLocations.length > 0
|
|
5438
|
+
? product.pickupLocations
|
|
5439
|
+
: undefined
|
|
5440
|
+
}
|
|
5441
|
+
destinations={product.destinations}
|
|
5442
|
+
pickupLocationId={pickupLocationId}
|
|
5443
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
5444
|
+
selectedPickupLocation={selectedPickupLocation}
|
|
5445
|
+
highlightedPickupLocationIds={highlightedPickupLocationIds}
|
|
5446
|
+
onLocationSelect={(locationId) => {
|
|
5447
|
+
setPickupLocationId(locationId);
|
|
5448
|
+
setError('');
|
|
5449
|
+
if (locationId === null && pickupLocationSkipped) {
|
|
5450
|
+
setPickupLocationSkipped(false);
|
|
5451
|
+
} else if (locationId !== null) {
|
|
5452
|
+
setPickupLocationSkipped(false);
|
|
5453
|
+
}
|
|
5454
|
+
}}
|
|
5455
|
+
onSkip={() => {
|
|
5456
|
+
setPickupLocationSkipped(true);
|
|
5457
|
+
setPickupLocationId(null);
|
|
5458
|
+
setError('');
|
|
5459
|
+
}}
|
|
5460
|
+
onChangePickup={() => {
|
|
5461
|
+
setPickupLocationId(null);
|
|
4369
5462
|
setPickupLocationSkipped(false);
|
|
5463
|
+
}}
|
|
5464
|
+
termsAccepted={termsAccepted}
|
|
5465
|
+
onTermsChange={(checked) => {
|
|
5466
|
+
setTermsAccepted(checked);
|
|
5467
|
+
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
5468
|
+
}}
|
|
5469
|
+
isAdmin={isAdmin}
|
|
5470
|
+
showCommunicationAdminSection={!isChangeFlow}
|
|
5471
|
+
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
5472
|
+
disableAutoCommunications={disableAutoCommunications}
|
|
5473
|
+
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
5474
|
+
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
5475
|
+
error={checkoutFormError}
|
|
5476
|
+
loading={loading}
|
|
5477
|
+
totalQuantity={totalQuantity}
|
|
5478
|
+
onCheckout={handleCheckout}
|
|
5479
|
+
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
5480
|
+
hideSubmitButton={
|
|
5481
|
+
showCheckoutModal ||
|
|
5482
|
+
showAdminPaymentChoice ||
|
|
5483
|
+
(isChangeFlow && (!hasEffectiveChangeSelection || isChangeQuoteBlocked))
|
|
4370
5484
|
}
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
4388
|
-
disableAutoCommunications={disableAutoCommunications}
|
|
4389
|
-
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
4390
|
-
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
4391
|
-
error={checkoutFormError}
|
|
4392
|
-
loading={loading}
|
|
4393
|
-
totalQuantity={totalQuantity}
|
|
4394
|
-
onCheckout={handleCheckout}
|
|
4395
|
-
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
4396
|
-
hideSubmitButton={
|
|
4397
|
-
showCheckoutModal ||
|
|
4398
|
-
showAdminPaymentChoice ||
|
|
4399
|
-
(isChangeFlow && (!hasChangeSelection || isChangeQuoteBlocked))
|
|
4400
|
-
}
|
|
4401
|
-
submitDisabled={changeFlowSubmitDisabled}
|
|
4402
|
-
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
4403
|
-
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
4404
|
-
attributionConfirmed={partnerAttributionConfirmed}
|
|
4405
|
-
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
4406
|
-
/>
|
|
5485
|
+
submitDisabled={changeFlowSubmitDisabled}
|
|
5486
|
+
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
5487
|
+
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
5488
|
+
attributionConfirmed={partnerAttributionConfirmed}
|
|
5489
|
+
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
5490
|
+
lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
|
|
5491
|
+
onLineAmountInputChange={
|
|
5492
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
|
|
5493
|
+
}
|
|
5494
|
+
onLineAmountInputBlur={
|
|
5495
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
|
|
5496
|
+
}
|
|
5497
|
+
onLineAmountReset={
|
|
5498
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
5499
|
+
}
|
|
5500
|
+
/>
|
|
4407
5501
|
)}
|
|
4408
5502
|
</div>
|
|
4409
5503
|
</>
|