@ticketboothapp/booking 1.2.55 → 1.2.58
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 +691 -169
- 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/ItineraryBox.tsx +2 -2
- 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 +37 -2
- 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 +47 -0
- package/src/index.ts +19 -0
- package/src/runtime/types.ts +2 -1
|
@@ -76,6 +76,7 @@ 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
|
|
|
81
82
|
/** Live selection snapshot for change-booking compare UI (parent dialog). */
|
|
@@ -134,6 +135,14 @@ function extractTrailingQty(label: string): { baseLabel: string; qty: number } {
|
|
|
134
135
|
return { baseLabel, qty };
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
function normalizeLineLabelForCompare(label: string): string {
|
|
139
|
+
return label
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.replace(/\([^)]*\)/g, '')
|
|
142
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
143
|
+
.trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
137
146
|
function deriveAddOnSelectionsFromReceiptLines(
|
|
138
147
|
addOns: AddOn[],
|
|
139
148
|
lines: Array<{ type?: string; label?: string; amount?: number; quantity?: number }>
|
|
@@ -308,6 +317,11 @@ interface BookingFlowProps {
|
|
|
308
317
|
availabilityPricingProfileId?: string | null;
|
|
309
318
|
/** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
|
|
310
319
|
availabilityCancellationPolicyProfileId?: string | null;
|
|
320
|
+
/**
|
|
321
|
+
* Provider dashboard: with `mode="change"`, submit via this callback instead of customer self-serve
|
|
322
|
+
* quote and payment (`quoteChangeBooking`, Stripe).
|
|
323
|
+
*/
|
|
324
|
+
onChangeBooking?: (data: ProviderDashboardChangeBookingPayload) => Promise<void>;
|
|
311
325
|
}
|
|
312
326
|
|
|
313
327
|
function parseAvailabilityDateTime(value: string): Date {
|
|
@@ -602,7 +616,18 @@ export function BookingFlow({
|
|
|
602
616
|
partnerPortalBooking = false,
|
|
603
617
|
availabilityPricingProfileId,
|
|
604
618
|
availabilityCancellationPolicyProfileId,
|
|
619
|
+
onChangeBooking,
|
|
605
620
|
}: BookingFlowProps) {
|
|
621
|
+
const isManualOverrideEligibleLine = (line: { editable: boolean; type?: string; label?: string }): boolean => {
|
|
622
|
+
if (!line.editable) return false;
|
|
623
|
+
const type = (line.type ?? '').toUpperCase();
|
|
624
|
+
const label = (line.label ?? '').toLowerCase();
|
|
625
|
+
const isPromoLikeType =
|
|
626
|
+
type.includes('PROMO') || type.includes('DISCOUNT') || type.includes('VOUCHER') || type.includes('GIFT');
|
|
627
|
+
const isPromoLikeLabel =
|
|
628
|
+
label.includes('promo') || label.includes('discount') || label.includes('voucher') || label.includes('gift');
|
|
629
|
+
return !(isPromoLikeType || isPromoLikeLabel);
|
|
630
|
+
};
|
|
606
631
|
const { env, strings: defaultStrings, analytics, catalog } = useBookingHost();
|
|
607
632
|
const { t } = useTranslations();
|
|
608
633
|
const { locale } = useLocale();
|
|
@@ -638,6 +663,8 @@ export function BookingFlow({
|
|
|
638
663
|
changeWindowHoursBefore?: number | null;
|
|
639
664
|
} | null>(null);
|
|
640
665
|
const cancellationPolicyRef = useRef<HTMLDivElement>(null);
|
|
666
|
+
/** Dedupe parent updates from `onChangeFlowSelectionPreview` when serialized preview is unchanged. */
|
|
667
|
+
const lastChangeFlowPreviewKeyRef = useRef<string | null>(null);
|
|
641
668
|
const [promoCodeValidating, setPromoCodeValidating] = useState(false);
|
|
642
669
|
const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
|
|
643
670
|
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
@@ -673,6 +700,8 @@ export function BookingFlow({
|
|
|
673
700
|
const [precomputedPricesByOption, setPrecomputedPricesByOption] = useState<Record<string, PrecomputedPricesByCategory> | null>(null);
|
|
674
701
|
const pricingConfigSetRef = useRef(false); // Track if pricingConfig has been set (optimize: only set once)
|
|
675
702
|
const fetchingRef = useRef(false); // Prevent concurrent fetches
|
|
703
|
+
const hasLoadedAvailabilitiesRef = useRef(false); // First successful availability paint completed
|
|
704
|
+
const inFlightRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range currently being fetched
|
|
676
705
|
const fetchedRangesRef = useRef<Array<{ start: Date; end: Date }>>([]); // Track fetched date ranges
|
|
677
706
|
const pendingRangeRef = useRef<{ start: Date; end: Date } | null>(null); // Range to fetch when current fetch completes (user navigated during fetch)
|
|
678
707
|
const [visibleRange, setVisibleRange] = useState<{ start: Date; end: Date } | null>(null);
|
|
@@ -737,6 +766,8 @@ export function BookingFlow({
|
|
|
737
766
|
const hasAutoSelectedPartnerPickupRef = useRef(false);
|
|
738
767
|
const handleDateSelectRef = useRef<(date: string) => void>(() => {});
|
|
739
768
|
const isChangeFlow = mode === 'change';
|
|
769
|
+
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
770
|
+
const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
|
|
740
771
|
|
|
741
772
|
useEffect(() => {
|
|
742
773
|
setPartnerAttributionConfirmed(false);
|
|
@@ -746,12 +777,14 @@ export function BookingFlow({
|
|
|
746
777
|
* user picks a different return time — baseline is the first auto-selected return for this outbound.
|
|
747
778
|
*/
|
|
748
779
|
const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
780
|
+
/** Any change flow (self-serve or provider): promo from booking is fixed — show read-only, never add new. */
|
|
781
|
+
const lockedPromoCode =
|
|
782
|
+
isChangeFlow && initialValues?.promoCode?.trim()
|
|
783
|
+
? initialValues.promoCode.trim().toUpperCase()
|
|
784
|
+
: null;
|
|
785
|
+
/** Public self-serve only: cannot reduce tickets below original counts. */
|
|
753
786
|
const changeBookingMinimumQuantities = useMemo(() => {
|
|
754
|
-
if (!
|
|
787
|
+
if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
|
|
755
788
|
const m: Record<string, number> = {};
|
|
756
789
|
for (const item of initialValues.bookingItems) {
|
|
757
790
|
const key = item.category?.trim();
|
|
@@ -759,7 +792,7 @@ export function BookingFlow({
|
|
|
759
792
|
m[key] = Math.max(0, Number(item.count) || 0);
|
|
760
793
|
}
|
|
761
794
|
return m;
|
|
762
|
-
}, [
|
|
795
|
+
}, [isCustomerSelfServeChange, initialValues?.bookingItems]);
|
|
763
796
|
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
764
797
|
reservationReference: string;
|
|
765
798
|
reservationExpiration?: string;
|
|
@@ -1068,6 +1101,14 @@ export function BookingFlow({
|
|
|
1068
1101
|
async function fetchAvailabilities() {
|
|
1069
1102
|
// Prevent concurrent fetches - store range to fetch when current one completes
|
|
1070
1103
|
if (fetchingRef.current && visibleRange) {
|
|
1104
|
+
const inFlight = inFlightRangeRef.current;
|
|
1105
|
+
if (
|
|
1106
|
+
inFlight &&
|
|
1107
|
+
inFlight.start.getTime() === visibleRange.start.getTime() &&
|
|
1108
|
+
inFlight.end.getTime() === visibleRange.end.getTime()
|
|
1109
|
+
) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1071
1112
|
pendingRangeRef.current = { start: visibleRange.start, end: visibleRange.end };
|
|
1072
1113
|
return;
|
|
1073
1114
|
}
|
|
@@ -1119,6 +1160,9 @@ export function BookingFlow({
|
|
|
1119
1160
|
const isStale = availabilitiesCache?.isStale(cached) ?? false;
|
|
1120
1161
|
if (cacheCoversRange) {
|
|
1121
1162
|
setAvailabilities(cached.availabilities);
|
|
1163
|
+
if (cached.availabilities.length > 0) {
|
|
1164
|
+
hasLoadedAvailabilitiesRef.current = true;
|
|
1165
|
+
}
|
|
1122
1166
|
if (cached.pricingConfig) {
|
|
1123
1167
|
setPricingConfig(cached.pricingConfig);
|
|
1124
1168
|
pricingConfigSetRef.current = true;
|
|
@@ -1137,6 +1181,9 @@ export function BookingFlow({
|
|
|
1137
1181
|
}
|
|
1138
1182
|
// Partial cache: show cached data immediately, then fetch missing range below
|
|
1139
1183
|
setAvailabilities(cached.availabilities);
|
|
1184
|
+
if (cached.availabilities.length > 0) {
|
|
1185
|
+
hasLoadedAvailabilitiesRef.current = true;
|
|
1186
|
+
}
|
|
1140
1187
|
if (cached.pricingConfig) {
|
|
1141
1188
|
setPricingConfig(cached.pricingConfig);
|
|
1142
1189
|
pricingConfigSetRef.current = true;
|
|
@@ -1160,8 +1207,14 @@ export function BookingFlow({
|
|
|
1160
1207
|
}
|
|
1161
1208
|
|
|
1162
1209
|
const hasPartialCache = cached && cached.availabilities.length > 0;
|
|
1210
|
+
const shouldUsePrimaryLoader =
|
|
1211
|
+
!hasPartialCache && !hasLoadedAvailabilitiesRef.current;
|
|
1163
1212
|
fetchingRef.current = true;
|
|
1164
|
-
|
|
1213
|
+
inFlightRangeRef.current = {
|
|
1214
|
+
start: new Date(clampedStart),
|
|
1215
|
+
end: new Date(clampedEnd),
|
|
1216
|
+
};
|
|
1217
|
+
if (shouldUsePrimaryLoader) setLoadingAvailabilities(true);
|
|
1165
1218
|
else setIsFetchingMoreAvailabilities(true);
|
|
1166
1219
|
|
|
1167
1220
|
try {
|
|
@@ -1218,6 +1271,9 @@ export function BookingFlow({
|
|
|
1218
1271
|
|
|
1219
1272
|
const results = await Promise.all(availabilityPromises);
|
|
1220
1273
|
const allFetchedAvailabilities = results.flatMap(r => r.availabilities);
|
|
1274
|
+
if (allFetchedAvailabilities.length > 0) {
|
|
1275
|
+
hasLoadedAvailabilitiesRef.current = true;
|
|
1276
|
+
}
|
|
1221
1277
|
setPrecomputedPricesByOption(prev => {
|
|
1222
1278
|
const next = { ...(prev || {}) };
|
|
1223
1279
|
results.forEach(r => {
|
|
@@ -1298,6 +1354,7 @@ export function BookingFlow({
|
|
|
1298
1354
|
setLoadingAvailabilities(false);
|
|
1299
1355
|
setIsFetchingMoreAvailabilities(false);
|
|
1300
1356
|
fetchingRef.current = false;
|
|
1357
|
+
inFlightRangeRef.current = null;
|
|
1301
1358
|
// If user navigated during fetch, trigger fetch for the pending range
|
|
1302
1359
|
const pending = pendingRangeRef.current;
|
|
1303
1360
|
if (pending) {
|
|
@@ -1645,27 +1702,27 @@ export function BookingFlow({
|
|
|
1645
1702
|
|
|
1646
1703
|
const initialAddOnMinQtyByKey = useMemo(() => {
|
|
1647
1704
|
const map = new Map<string, number>();
|
|
1648
|
-
if (!
|
|
1705
|
+
if (!isCustomerSelfServeChange) return map;
|
|
1649
1706
|
for (const sel of initialAddOnBaselineSelections) {
|
|
1650
1707
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
1651
1708
|
map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1652
1709
|
}
|
|
1653
1710
|
return map;
|
|
1654
|
-
}, [
|
|
1711
|
+
}, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
|
|
1655
1712
|
|
|
1656
1713
|
const initialAddOnMinTotalByAddOnId = useMemo(() => {
|
|
1657
1714
|
const map = new Map<string, number>();
|
|
1658
|
-
if (!
|
|
1715
|
+
if (!isCustomerSelfServeChange) return map;
|
|
1659
1716
|
for (const sel of initialAddOnBaselineSelections) {
|
|
1660
1717
|
const addOnId = sel.addOnId.trim();
|
|
1661
1718
|
map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
|
|
1662
1719
|
}
|
|
1663
1720
|
return map;
|
|
1664
|
-
}, [
|
|
1721
|
+
}, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
|
|
1665
1722
|
|
|
1666
1723
|
const applyChangeFlowAddOnFloor = useCallback(
|
|
1667
1724
|
(nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => {
|
|
1668
|
-
if (!
|
|
1725
|
+
if (!isCustomerSelfServeChange || initialAddOnMinQtyByKey.size === 0) return nextSelections;
|
|
1669
1726
|
const qtyByKey = new Map<string, number>();
|
|
1670
1727
|
for (const sel of nextSelections) {
|
|
1671
1728
|
const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
|
|
@@ -1692,7 +1749,7 @@ export function BookingFlow({
|
|
|
1692
1749
|
return { addOnId, variantId, quantity: qty };
|
|
1693
1750
|
});
|
|
1694
1751
|
},
|
|
1695
|
-
[
|
|
1752
|
+
[isCustomerSelfServeChange, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
|
|
1696
1753
|
);
|
|
1697
1754
|
|
|
1698
1755
|
const updateAddOnSelections = useCallback(
|
|
@@ -2016,7 +2073,7 @@ export function BookingFlow({
|
|
|
2016
2073
|
[pricingConfig?.fees]
|
|
2017
2074
|
);
|
|
2018
2075
|
|
|
2019
|
-
const
|
|
2076
|
+
const changeFlowTicketBookedUnitPriceByCategory = useMemo(() => {
|
|
2020
2077
|
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return new Map<string, number>();
|
|
2021
2078
|
const amountByCategory = new Map<string, number>();
|
|
2022
2079
|
const qtyByCategory = new Map<string, number>();
|
|
@@ -2057,6 +2114,25 @@ export function BookingFlow({
|
|
|
2057
2114
|
if (totalAmount <= 0 || totalQty <= 0) return null;
|
|
2058
2115
|
return totalAmount / totalQty;
|
|
2059
2116
|
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2117
|
+
const changeFlowBookedFeeUnitByNormalizedLabel = useMemo(() => {
|
|
2118
|
+
const feeUnitByLabel = new Map<string, number>();
|
|
2119
|
+
if (!isChangeFlow || !originalReceipt?.lineItems?.length) return feeUnitByLabel;
|
|
2120
|
+
const fallbackBookedQty =
|
|
2121
|
+
(initialValues?.bookingItems ?? []).reduce((sum, item) => sum + Math.max(0, Number(item.count) || 0), 0);
|
|
2122
|
+
for (const line of originalReceipt.lineItems) {
|
|
2123
|
+
const type = (line.type || '').trim().toUpperCase();
|
|
2124
|
+
if (!line.label || type === 'TICKET' || type === 'RETURN_OPTION' || type === 'TAX') continue;
|
|
2125
|
+
if (type.includes('PROMO') || type.includes('VOUCHER') || type.includes('GIFT')) continue;
|
|
2126
|
+
const amount = Number(line.amount ?? 0);
|
|
2127
|
+
const qtyRaw = Number(line.quantity ?? 0);
|
|
2128
|
+
const qty = qtyRaw > 0 ? qtyRaw : fallbackBookedQty;
|
|
2129
|
+
if (!Number.isFinite(amount) || !Number.isFinite(qty) || amount <= 0 || qty <= 0) continue;
|
|
2130
|
+
const key = normalizeLineLabelForCompare(line.label);
|
|
2131
|
+
if (!key) continue;
|
|
2132
|
+
feeUnitByLabel.set(key, amount / qty);
|
|
2133
|
+
}
|
|
2134
|
+
return feeUnitByLabel;
|
|
2135
|
+
}, [isChangeFlow, originalReceipt?.lineItems, initialValues?.bookingItems]);
|
|
2060
2136
|
|
|
2061
2137
|
const returnOptionsWithFloor = useMemo(() => {
|
|
2062
2138
|
const options = selectedAvailability?.returnOptions ?? [];
|
|
@@ -2130,13 +2206,11 @@ export function BookingFlow({
|
|
|
2130
2206
|
const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
|
|
2131
2207
|
const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
|
|
2132
2208
|
const built = buildRate(rate.category, backendPriceCAD, backendInDisplayCurrency, baseInDisplayCurrency, rate.appliedAdjustments ?? rate.applied_adjustments ?? []);
|
|
2133
|
-
const floorUnitPrice = changeFlowTicketPriceFloorByCategory.get(rate.category.toUpperCase());
|
|
2134
|
-
const price = floorUnitPrice != null ? Math.max(built.price, floorUnitPrice) : built.price;
|
|
2135
2209
|
return {
|
|
2136
2210
|
category: rate.category,
|
|
2137
2211
|
rateId: rate.rateId || rate.category,
|
|
2138
2212
|
available: rate.available,
|
|
2139
|
-
price,
|
|
2213
|
+
price: built.price,
|
|
2140
2214
|
priceCAD: built.priceCAD,
|
|
2141
2215
|
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2142
2216
|
appliedAdjustments: built.appliedAdjustments,
|
|
@@ -2146,19 +2220,17 @@ export function BookingFlow({
|
|
|
2146
2220
|
const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
|
|
2147
2221
|
const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
|
|
2148
2222
|
const built = buildRate(p.category, priceCADFromApi, backendInDisplayCurrency, baseInDisplayCurrency, []);
|
|
2149
|
-
const floorUnitPrice = changeFlowTicketPriceFloorByCategory.get(p.category.toUpperCase());
|
|
2150
|
-
const price = floorUnitPrice != null ? Math.max(built.price, floorUnitPrice) : built.price;
|
|
2151
2223
|
return {
|
|
2152
2224
|
category: p.category,
|
|
2153
2225
|
rateId: p.category,
|
|
2154
2226
|
available: selectedAvailability.vacancies,
|
|
2155
|
-
price,
|
|
2227
|
+
price: built.price,
|
|
2156
2228
|
priceCAD: built.priceCAD,
|
|
2157
2229
|
baseInDisplayCurrency: built.baseInDisplayCurrency,
|
|
2158
2230
|
appliedAdjustments: built.appliedAdjustments,
|
|
2159
2231
|
};
|
|
2160
2232
|
}) || [];
|
|
2161
|
-
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView
|
|
2233
|
+
}, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
|
|
2162
2234
|
|
|
2163
2235
|
// Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
|
|
2164
2236
|
const getPriceBreakdown = useCallback((category: string, priceCAD: number, baseInDisplayCurrency: number | undefined, appliedAdjustments: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }> = []): PriceBreakdownData | null => {
|
|
@@ -2194,6 +2266,51 @@ export function BookingFlow({
|
|
|
2194
2266
|
[quantities, pricing, selectedReturnOptionWithFloor, pricingConfig, currency, hasFees, cancellationPolicyId]
|
|
2195
2267
|
);
|
|
2196
2268
|
const { totalQuantity, subtotal, tax, total: totalFromSummary, feeLineItems, returnPriceAdjustment, cancellationPolicyFee, isTaxIncludedInPrice, ticketLineItems } = orderSummary;
|
|
2269
|
+
const changeFlowInitialTicketQtyByCategory = useMemo(() => {
|
|
2270
|
+
const qtyByCategory = new Map<string, number>();
|
|
2271
|
+
if (!isChangeFlow || !initialValues?.bookingItems?.length) return qtyByCategory;
|
|
2272
|
+
for (const item of initialValues.bookingItems) {
|
|
2273
|
+
const category = item.category?.trim().toUpperCase();
|
|
2274
|
+
if (!category) continue;
|
|
2275
|
+
qtyByCategory.set(category, Math.max(0, Number(item.count) || 0));
|
|
2276
|
+
}
|
|
2277
|
+
return qtyByCategory;
|
|
2278
|
+
}, [isChangeFlow, initialValues?.bookingItems]);
|
|
2279
|
+
const changeFlowPreserveBookedTicketPricing = useMemo(() => {
|
|
2280
|
+
if (!isChangeFlow || !selectedAvailability || !initialValues?.dateTime) return false;
|
|
2281
|
+
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
2282
|
+
const initialMs = parseAvailabilityDateTime(initialValues.dateTime).getTime();
|
|
2283
|
+
if (selectedMs !== initialMs) return false;
|
|
2284
|
+
const selectedOptionId = selectedAvailability.productOptionId?.trim() || null;
|
|
2285
|
+
const initialOptionId = initialValues.productOptionId?.trim() || null;
|
|
2286
|
+
// If option IDs are missing, still protect pricing when date/time is unchanged.
|
|
2287
|
+
if (!selectedOptionId || !initialOptionId) return true;
|
|
2288
|
+
return selectedOptionId === initialOptionId;
|
|
2289
|
+
}, [isChangeFlow, selectedAvailability, initialValues?.dateTime, initialValues?.productOptionId]);
|
|
2290
|
+
const changeFlowProtectedTicketSubtotal = useMemo(() => {
|
|
2291
|
+
const currentTicketSubtotal = ticketLineItems.reduce(
|
|
2292
|
+
(sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0),
|
|
2293
|
+
0,
|
|
2294
|
+
);
|
|
2295
|
+
if (!changeFlowPreserveBookedTicketPricing) return currentTicketSubtotal;
|
|
2296
|
+
return ticketLineItems.reduce((sum, line) => {
|
|
2297
|
+
const category = line.category?.trim().toUpperCase();
|
|
2298
|
+
const qty = Math.max(0, Number(line.qty) || 0);
|
|
2299
|
+
const liveUnitPrice = qty > 0 ? line.itemTotal / qty : 0;
|
|
2300
|
+
if (!category || qty <= 0) return sum;
|
|
2301
|
+
const bookedUnitPrice = changeFlowTicketBookedUnitPriceByCategory.get(category);
|
|
2302
|
+
if (bookedUnitPrice == null) return sum + line.itemTotal;
|
|
2303
|
+
const baselineQty = Math.max(0, changeFlowInitialTicketQtyByCategory.get(category) ?? 0);
|
|
2304
|
+
const protectedQty = Math.min(qty, baselineQty);
|
|
2305
|
+
const incrementalQty = Math.max(0, qty - baselineQty);
|
|
2306
|
+
return sum + protectedQty * bookedUnitPrice + incrementalQty * liveUnitPrice;
|
|
2307
|
+
}, 0);
|
|
2308
|
+
}, [
|
|
2309
|
+
changeFlowPreserveBookedTicketPricing,
|
|
2310
|
+
ticketLineItems,
|
|
2311
|
+
changeFlowTicketBookedUnitPriceByCategory,
|
|
2312
|
+
changeFlowInitialTicketQtyByCategory,
|
|
2313
|
+
]);
|
|
2197
2314
|
/** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
|
|
2198
2315
|
const effectivePartySizeCap = useMemo(() => {
|
|
2199
2316
|
if (!selectedAvailability) return 0;
|
|
@@ -2257,7 +2374,63 @@ export function BookingFlow({
|
|
|
2257
2374
|
}, [addOnSelections, addOns]);
|
|
2258
2375
|
|
|
2259
2376
|
// Effective subtotal includes add-ons (for promo discount and total)
|
|
2260
|
-
const
|
|
2377
|
+
const currentTicketSubtotal = useMemo(
|
|
2378
|
+
() => ticketLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.itemTotal) || 0), 0),
|
|
2379
|
+
[ticketLineItems],
|
|
2380
|
+
);
|
|
2381
|
+
const changeFlowInitialTotalTicketQty = useMemo(() => {
|
|
2382
|
+
let sum = 0;
|
|
2383
|
+
for (const qty of changeFlowInitialTicketQtyByCategory.values()) sum += qty;
|
|
2384
|
+
return sum;
|
|
2385
|
+
}, [changeFlowInitialTicketQtyByCategory]);
|
|
2386
|
+
const currentFeeSubtotal = useMemo(
|
|
2387
|
+
() => feeLineItems.reduce((sum, line) => sum + Math.max(0, Number(line.totalAmount) || 0), 0),
|
|
2388
|
+
[feeLineItems],
|
|
2389
|
+
);
|
|
2390
|
+
const changeFlowProtectedFeeSubtotal = useMemo(() => {
|
|
2391
|
+
if (!changeFlowPreserveBookedTicketPricing || totalQuantity <= 0) return currentFeeSubtotal;
|
|
2392
|
+
return feeLineItems.reduce((sum, line) => {
|
|
2393
|
+
const key = normalizeLineLabelForCompare(line.name || '');
|
|
2394
|
+
const currentUnitPrice = line.totalAmount / totalQuantity;
|
|
2395
|
+
const bookedUnitPrice = key ? changeFlowBookedFeeUnitByNormalizedLabel.get(key) : null;
|
|
2396
|
+
if (bookedUnitPrice == null) return sum + line.totalAmount;
|
|
2397
|
+
const protectedQty = Math.min(totalQuantity, changeFlowInitialTotalTicketQty);
|
|
2398
|
+
const incrementalQty = Math.max(0, totalQuantity - changeFlowInitialTotalTicketQty);
|
|
2399
|
+
return sum + protectedQty * bookedUnitPrice + incrementalQty * currentUnitPrice;
|
|
2400
|
+
}, 0);
|
|
2401
|
+
}, [
|
|
2402
|
+
changeFlowPreserveBookedTicketPricing,
|
|
2403
|
+
totalQuantity,
|
|
2404
|
+
currentFeeSubtotal,
|
|
2405
|
+
feeLineItems,
|
|
2406
|
+
changeFlowBookedFeeUnitByNormalizedLabel,
|
|
2407
|
+
changeFlowInitialTotalTicketQty,
|
|
2408
|
+
]);
|
|
2409
|
+
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2410
|
+
if (!changeFlowPreserveBookedTicketPricing || totalQuantity <= 0) return returnPriceAdjustment;
|
|
2411
|
+
if (changeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2412
|
+
const currentUnitPrice = returnPriceAdjustment / totalQuantity;
|
|
2413
|
+
const protectedQty = Math.min(totalQuantity, changeFlowInitialTotalTicketQty);
|
|
2414
|
+
const incrementalQty = Math.max(0, totalQuantity - changeFlowInitialTotalTicketQty);
|
|
2415
|
+
return protectedQty * changeFlowReturnUnitFloorPerPerson + incrementalQty * currentUnitPrice;
|
|
2416
|
+
}, [
|
|
2417
|
+
changeFlowPreserveBookedTicketPricing,
|
|
2418
|
+
totalQuantity,
|
|
2419
|
+
returnPriceAdjustment,
|
|
2420
|
+
changeFlowReturnUnitFloorPerPerson,
|
|
2421
|
+
changeFlowInitialTotalTicketQty,
|
|
2422
|
+
]);
|
|
2423
|
+
const effectiveSubtotalBeforeAddOns =
|
|
2424
|
+
isChangeFlow && changeFlowPreserveBookedTicketPricing
|
|
2425
|
+
? subtotal -
|
|
2426
|
+
currentTicketSubtotal -
|
|
2427
|
+
currentFeeSubtotal -
|
|
2428
|
+
returnPriceAdjustment +
|
|
2429
|
+
changeFlowProtectedTicketSubtotal +
|
|
2430
|
+
changeFlowProtectedFeeSubtotal +
|
|
2431
|
+
changeFlowProtectedReturnAdjustment
|
|
2432
|
+
: subtotal;
|
|
2433
|
+
const effectiveSubtotal = effectiveSubtotalBeforeAddOns + addOnTotal;
|
|
2261
2434
|
|
|
2262
2435
|
/** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
|
|
2263
2436
|
const quantitiesSignature = useMemo(
|
|
@@ -2289,6 +2462,30 @@ export function BookingFlow({
|
|
|
2289
2462
|
return [...feeLineItems, ...addOnLines];
|
|
2290
2463
|
}, [feeLineItems, addOnSelections, addOns]);
|
|
2291
2464
|
|
|
2465
|
+
const providerPricingUi = flowUi?.providerDashboardChangePricingUi;
|
|
2466
|
+
const providerQuotedLines = providerPricingUi?.quotedLines ?? [];
|
|
2467
|
+
const providerEditableLines = providerQuotedLines.filter((line) => isManualOverrideEligibleLine(line));
|
|
2468
|
+
const showProviderPricingInlineEditor =
|
|
2469
|
+
isProviderDashboardChange && isAdmin && isChangeFlow && (
|
|
2470
|
+
providerPricingUi?.loading ||
|
|
2471
|
+
providerPricingUi?.error != null ||
|
|
2472
|
+
providerQuotedLines.length > 0
|
|
2473
|
+
);
|
|
2474
|
+
const normalizeReceiptLabel = (value: string): string =>
|
|
2475
|
+
value
|
|
2476
|
+
.toLowerCase()
|
|
2477
|
+
.replace(/\([^)]*\)/g, '')
|
|
2478
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
2479
|
+
.trim();
|
|
2480
|
+
const providerEditableLineByNormalizedLabel = useMemo(() => {
|
|
2481
|
+
const m = new Map<string, (typeof providerEditableLines)[number]>();
|
|
2482
|
+
for (const line of providerEditableLines) {
|
|
2483
|
+
const key = normalizeReceiptLabel(line.label ?? '');
|
|
2484
|
+
if (key) m.set(key, line);
|
|
2485
|
+
}
|
|
2486
|
+
return m;
|
|
2487
|
+
}, [providerEditableLines]);
|
|
2488
|
+
|
|
2292
2489
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2293
2490
|
if (!selectedAvailability) return [];
|
|
2294
2491
|
return [
|
|
@@ -2302,6 +2499,13 @@ export function BookingFlow({
|
|
|
2302
2499
|
);
|
|
2303
2500
|
return {
|
|
2304
2501
|
kind: 'ticket',
|
|
2502
|
+
lineKey:
|
|
2503
|
+
showProviderPricingInlineEditor
|
|
2504
|
+
? providerEditableLineByNormalizedLabel.get(normalizeReceiptLabel(line.category))?.lineKey
|
|
2505
|
+
: undefined,
|
|
2506
|
+
editable:
|
|
2507
|
+
showProviderPricingInlineEditor &&
|
|
2508
|
+
providerEditableLineByNormalizedLabel.has(normalizeReceiptLabel(line.category)),
|
|
2305
2509
|
category: line.category,
|
|
2306
2510
|
qty: line.qty,
|
|
2307
2511
|
itemTotal: line.itemTotal,
|
|
@@ -2336,6 +2540,25 @@ export function BookingFlow({
|
|
|
2336
2540
|
fee.name.toLowerCase().includes('license'));
|
|
2337
2541
|
return {
|
|
2338
2542
|
kind: 'line' as const,
|
|
2543
|
+
lineKey:
|
|
2544
|
+
showProviderPricingInlineEditor
|
|
2545
|
+
? providerEditableLineByNormalizedLabel.get(
|
|
2546
|
+
normalizeReceiptLabel(
|
|
2547
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
2548
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2549
|
+
: fee.name
|
|
2550
|
+
)
|
|
2551
|
+
)?.lineKey
|
|
2552
|
+
: undefined,
|
|
2553
|
+
editable:
|
|
2554
|
+
showProviderPricingInlineEditor &&
|
|
2555
|
+
providerEditableLineByNormalizedLabel.has(
|
|
2556
|
+
normalizeReceiptLabel(
|
|
2557
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
2558
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2559
|
+
: fee.name
|
|
2560
|
+
)
|
|
2561
|
+
),
|
|
2339
2562
|
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2340
2563
|
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2341
2564
|
: fee.name,
|
|
@@ -2362,6 +2585,8 @@ export function BookingFlow({
|
|
|
2362
2585
|
effectiveCancellationPolicyLabel,
|
|
2363
2586
|
feeLineItemsWithAddOns,
|
|
2364
2587
|
feeLineItems,
|
|
2588
|
+
showProviderPricingInlineEditor,
|
|
2589
|
+
providerEditableLineByNormalizedLabel,
|
|
2365
2590
|
]);
|
|
2366
2591
|
|
|
2367
2592
|
// Promo discount from backend (order-level only; rates are pre-promo)
|
|
@@ -2493,10 +2718,30 @@ export function BookingFlow({
|
|
|
2493
2718
|
: totalPrice;
|
|
2494
2719
|
const changeSelectionDetails = useMemo(() => {
|
|
2495
2720
|
if (!isChangeFlow || !initialValues) {
|
|
2496
|
-
return {
|
|
2721
|
+
return {
|
|
2722
|
+
hasChangesFromInitial: true,
|
|
2723
|
+
hasOperationalChangesFromInitial: true,
|
|
2724
|
+
dateChanged: false,
|
|
2725
|
+
ticketsChanged: false,
|
|
2726
|
+
optionChanged: false,
|
|
2727
|
+
pickupChanged: false,
|
|
2728
|
+
countsChanged: false,
|
|
2729
|
+
addOnsChanged: false,
|
|
2730
|
+
returnChanged: false,
|
|
2731
|
+
};
|
|
2497
2732
|
}
|
|
2498
2733
|
if (!selectedAvailability) {
|
|
2499
|
-
return {
|
|
2734
|
+
return {
|
|
2735
|
+
hasChangesFromInitial: false,
|
|
2736
|
+
hasOperationalChangesFromInitial: false,
|
|
2737
|
+
dateChanged: false,
|
|
2738
|
+
ticketsChanged: false,
|
|
2739
|
+
optionChanged: false,
|
|
2740
|
+
pickupChanged: false,
|
|
2741
|
+
countsChanged: false,
|
|
2742
|
+
addOnsChanged: false,
|
|
2743
|
+
returnChanged: false,
|
|
2744
|
+
};
|
|
2500
2745
|
}
|
|
2501
2746
|
const initialMs = initialValues.dateTime ? parseAvailabilityDateTime(initialValues.dateTime).getTime() : null;
|
|
2502
2747
|
const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
|
|
@@ -2508,7 +2753,13 @@ export function BookingFlow({
|
|
|
2508
2753
|
const optionChanged = Boolean(
|
|
2509
2754
|
initialOpt && selectedOpt && initialOpt !== selectedOpt
|
|
2510
2755
|
);
|
|
2511
|
-
const
|
|
2756
|
+
const normalizePickupId = (value: string | null | undefined) => {
|
|
2757
|
+
const trimmed = value?.trim();
|
|
2758
|
+
return trimmed ? trimmed : null;
|
|
2759
|
+
};
|
|
2760
|
+
const pickupChanged =
|
|
2761
|
+
normalizePickupId(initialValues.pickupLocationId ?? null) !==
|
|
2762
|
+
normalizePickupId(pickupLocationId ?? null);
|
|
2512
2763
|
const normalizeCounts = (items: Array<{ category: string; count: number }> | null | undefined) => {
|
|
2513
2764
|
const map = new Map<string, number>();
|
|
2514
2765
|
for (const item of items ?? []) {
|
|
@@ -2554,7 +2805,16 @@ export function BookingFlow({
|
|
|
2554
2805
|
let returnChanged = false;
|
|
2555
2806
|
if (hasReturnOptions && selectedReturnOption) {
|
|
2556
2807
|
if (initialReturnId && selectedReturnId) {
|
|
2557
|
-
|
|
2808
|
+
if (initialReturnId === selectedReturnId) {
|
|
2809
|
+
returnChanged = false;
|
|
2810
|
+
} else if (initialReturnDt && selectedReturnDt) {
|
|
2811
|
+
// Some refreshes can rotate return availability IDs for the same departure time.
|
|
2812
|
+
returnChanged =
|
|
2813
|
+
parseAvailabilityDateTime(initialReturnDt).getTime() !==
|
|
2814
|
+
parseAvailabilityDateTime(selectedReturnDt).getTime();
|
|
2815
|
+
} else {
|
|
2816
|
+
returnChanged = true;
|
|
2817
|
+
}
|
|
2558
2818
|
} else if (initialReturnDt && selectedReturnDt) {
|
|
2559
2819
|
returnChanged =
|
|
2560
2820
|
parseAvailabilityDateTime(initialReturnDt).getTime() !==
|
|
@@ -2571,9 +2831,22 @@ export function BookingFlow({
|
|
|
2571
2831
|
countsChanged ||
|
|
2572
2832
|
addOnsChanged ||
|
|
2573
2833
|
returnChanged,
|
|
2834
|
+
// Authoritative for "real user change" gating in provider dashboard:
|
|
2835
|
+
// ignore option-id noise and only consider user-visible booking deltas.
|
|
2836
|
+
hasOperationalChangesFromInitial:
|
|
2837
|
+
dateChanged ||
|
|
2838
|
+
pickupChanged ||
|
|
2839
|
+
countsChanged ||
|
|
2840
|
+
addOnsChanged ||
|
|
2841
|
+
returnChanged,
|
|
2574
2842
|
dateChanged,
|
|
2575
2843
|
// Tickets line corresponds to "option + ticket counts"; add-ons and pickup changes affect itinerary but not the ticket label.
|
|
2576
2844
|
ticketsChanged: Boolean(optionChanged || countsChanged),
|
|
2845
|
+
optionChanged,
|
|
2846
|
+
pickupChanged,
|
|
2847
|
+
countsChanged,
|
|
2848
|
+
addOnsChanged,
|
|
2849
|
+
returnChanged,
|
|
2577
2850
|
};
|
|
2578
2851
|
}, [
|
|
2579
2852
|
isChangeFlow,
|
|
@@ -2586,25 +2859,72 @@ export function BookingFlow({
|
|
|
2586
2859
|
addOnSelections,
|
|
2587
2860
|
initialAddOnMinQtyByKey,
|
|
2588
2861
|
]);
|
|
2589
|
-
const hasChangeSelection =
|
|
2862
|
+
const hasChangeSelection =
|
|
2863
|
+
isProviderDashboardChange
|
|
2864
|
+
? changeSelectionDetails.hasOperationalChangesFromInitial
|
|
2865
|
+
: changeSelectionDetails.hasChangesFromInitial;
|
|
2590
2866
|
|
|
2591
2867
|
const changeFlowNeedsServerPrice =
|
|
2592
|
-
|
|
2868
|
+
isCustomerSelfServeChange &&
|
|
2593
2869
|
hasChangeSelection &&
|
|
2594
2870
|
!!initialValues?.bookingReference?.trim() &&
|
|
2595
2871
|
!!lastName.trim();
|
|
2596
2872
|
|
|
2597
|
-
const isChangeQuoteBlocked =
|
|
2598
|
-
const requiresReturnInChangeFlow =
|
|
2873
|
+
const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
|
|
2874
|
+
const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
|
|
2599
2875
|
const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
|
|
2600
2876
|
|
|
2601
2877
|
const changeFlowSubmitDisabled =
|
|
2602
2878
|
isChangeFlow &&
|
|
2603
2879
|
(missingRequiredReturnSelection ||
|
|
2604
|
-
(
|
|
2880
|
+
(isCustomerSelfServeChange &&
|
|
2881
|
+
changeFlowNeedsServerPrice &&
|
|
2882
|
+
(changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError))));
|
|
2883
|
+
|
|
2884
|
+
const providerTotalsPreview =
|
|
2885
|
+
showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
|
|
2886
|
+
? providerPricingUi.totalsPreview
|
|
2887
|
+
: null;
|
|
2888
|
+
const displayChangeFlowProposedTotal = providerTotalsPreview
|
|
2889
|
+
? providerTotalsPreview.totalAmount
|
|
2890
|
+
: changeFlowProposedTotal;
|
|
2891
|
+
const displayChangeFlowSubtotal = providerTotalsPreview
|
|
2892
|
+
? providerTotalsPreview.subtotalBeforeTax
|
|
2893
|
+
: effectiveSubtotal;
|
|
2894
|
+
const displayChangeFlowTax = providerTotalsPreview
|
|
2895
|
+
? providerTotalsPreview.taxAmount
|
|
2896
|
+
: effectiveTax;
|
|
2897
|
+
const providerHasEditedLineOverrides =
|
|
2898
|
+
isProviderDashboardChange &&
|
|
2899
|
+
providerQuotedLines.some((line) => {
|
|
2900
|
+
if (!isManualOverrideEligibleLine(line)) return false;
|
|
2901
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
2902
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
2903
|
+
if (!Number.isFinite(parsed)) return false;
|
|
2904
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
2905
|
+
return Math.abs(rounded - line.amount) > 0.0001;
|
|
2906
|
+
});
|
|
2907
|
+
const providerHasAdditionalAdjustments =
|
|
2908
|
+
isProviderDashboardChange &&
|
|
2909
|
+
(providerPricingUi?.additionalAdjustments ?? []).some((adj) => {
|
|
2910
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
2911
|
+
const hasAmount = Number.isFinite(parsed) && parsed > 0;
|
|
2912
|
+
const currentAmount = hasAmount ? (Math.round(parsed * 100) / 100).toFixed(2) : '';
|
|
2913
|
+
const originalAmount = adj.originalAmountInput ?? '';
|
|
2914
|
+
const currentLabel = (adj.label ?? '').trim();
|
|
2915
|
+
const originalLabel = (adj.originalLabel ?? '').trim();
|
|
2916
|
+
const currentMode = adj.mode;
|
|
2917
|
+
const originalMode = adj.originalMode;
|
|
2918
|
+
if (!originalMode) return hasAmount || currentLabel.length > 0;
|
|
2919
|
+
return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
|
|
2920
|
+
});
|
|
2921
|
+
const hasEffectiveChangeSelection =
|
|
2922
|
+
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
2605
2923
|
|
|
2606
2924
|
const changeFlowClientEstimateDue = originalReceipt
|
|
2607
|
-
?
|
|
2925
|
+
? (isProviderDashboardChange
|
|
2926
|
+
? displayChangeFlowProposedTotal - originalReceipt.total
|
|
2927
|
+
: Math.max(displayChangeFlowProposedTotal - originalReceipt.total, 0))
|
|
2608
2928
|
: totalPrice;
|
|
2609
2929
|
|
|
2610
2930
|
/**
|
|
@@ -2612,12 +2932,24 @@ export function BookingFlow({
|
|
|
2612
2932
|
* Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
|
|
2613
2933
|
*/
|
|
2614
2934
|
const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
|
|
2615
|
-
const changeFlowAmountDue =
|
|
2616
|
-
|
|
2935
|
+
const changeFlowAmountDue = isChangeFlow
|
|
2936
|
+
? (() => {
|
|
2937
|
+
const rounded = Math.round(changeFlowAmountDueRaw * 100) / 100;
|
|
2938
|
+
return Math.abs(rounded) < 0.01 ? 0 : rounded;
|
|
2939
|
+
})()
|
|
2940
|
+
: changeFlowAmountDueRaw;
|
|
2617
2941
|
|
|
2618
2942
|
const changeCheckoutButtonLabel = (() => {
|
|
2619
2943
|
if (!isChangeFlow) return undefined;
|
|
2620
|
-
if (!
|
|
2944
|
+
if (!hasEffectiveChangeSelection) return undefined;
|
|
2945
|
+
if (isProviderDashboardChange) {
|
|
2946
|
+
const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
|
|
2947
|
+
return est > 0
|
|
2948
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
2949
|
+
: est < 0
|
|
2950
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
2951
|
+
: 'Change booking (no charge)';
|
|
2952
|
+
}
|
|
2621
2953
|
if (changeFlowNeedsServerPrice) {
|
|
2622
2954
|
if (changeQuoteLoading) {
|
|
2623
2955
|
const tr = t('booking.updatingPrice');
|
|
@@ -2653,8 +2985,39 @@ export function BookingFlow({
|
|
|
2653
2985
|
const checkoutFormError =
|
|
2654
2986
|
(error || '') ||
|
|
2655
2987
|
(missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
|
|
2656
|
-
(isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
2657
|
-
(changeQuoteFetchError ?? '');
|
|
2988
|
+
(isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
2989
|
+
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
2990
|
+
|
|
2991
|
+
const providerPricingOverrides =
|
|
2992
|
+
isProviderDashboardChange && providerQuotedLines.length > 0
|
|
2993
|
+
? providerQuotedLines
|
|
2994
|
+
.filter((line) => isManualOverrideEligibleLine(line))
|
|
2995
|
+
.map((line) => {
|
|
2996
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
2997
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
2998
|
+
if (!Number.isFinite(parsed)) return null;
|
|
2999
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3000
|
+
return Math.abs(rounded - line.amount) > 0.0001
|
|
3001
|
+
? { lineKey: line.lineKey, amount: rounded, reason: 'Provider dashboard override' }
|
|
3002
|
+
: null;
|
|
3003
|
+
})
|
|
3004
|
+
.filter((v): v is { lineKey: string; amount: number; reason: string } => v != null)
|
|
3005
|
+
: [];
|
|
3006
|
+
const providerAdditionalAdjustments =
|
|
3007
|
+
isProviderDashboardChange
|
|
3008
|
+
? (providerPricingUi?.additionalAdjustments ?? [])
|
|
3009
|
+
.map((adj) => {
|
|
3010
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3011
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
3012
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3013
|
+
const signed = adj.mode === 'DISCOUNT' ? -rounded : rounded;
|
|
3014
|
+
return {
|
|
3015
|
+
label: adj.label?.trim() || (signed < 0 ? 'Provider discount' : 'Provider charge'),
|
|
3016
|
+
amount: signed,
|
|
3017
|
+
};
|
|
3018
|
+
})
|
|
3019
|
+
.filter((v): v is { label: string; amount: number } => v != null)
|
|
3020
|
+
: [];
|
|
2658
3021
|
|
|
2659
3022
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
2660
3023
|
if (!isChangeFlow) return null;
|
|
@@ -2692,8 +3055,29 @@ export function BookingFlow({
|
|
|
2692
3055
|
]);
|
|
2693
3056
|
|
|
2694
3057
|
useEffect(() => {
|
|
2695
|
-
if (!isChangeFlow
|
|
2696
|
-
|
|
3058
|
+
if (!isChangeFlow) {
|
|
3059
|
+
lastChangeFlowPreviewKeyRef.current = null;
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
if (!onChangeFlowSelectionPreview) return;
|
|
3063
|
+
const next = changeFlowSelectionPreview;
|
|
3064
|
+
const key =
|
|
3065
|
+
next === null
|
|
3066
|
+
? 'null'
|
|
3067
|
+
: JSON.stringify({
|
|
3068
|
+
tourName: next.tourName,
|
|
3069
|
+
dateTime: next.dateTime,
|
|
3070
|
+
ticketsLine: next.ticketsLine,
|
|
3071
|
+
itinerarySteps: next.itinerarySteps,
|
|
3072
|
+
dateChanged: next.dateChanged,
|
|
3073
|
+
ticketsChanged: next.ticketsChanged,
|
|
3074
|
+
hasChangesFromInitial: next.hasChangesFromInitial,
|
|
3075
|
+
selectionTotal: next.selectionTotal,
|
|
3076
|
+
selectionCurrency: next.selectionCurrency,
|
|
3077
|
+
});
|
|
3078
|
+
if (key === lastChangeFlowPreviewKeyRef.current) return;
|
|
3079
|
+
lastChangeFlowPreviewKeyRef.current = key;
|
|
3080
|
+
onChangeFlowSelectionPreview(next);
|
|
2697
3081
|
}, [isChangeFlow, changeFlowSelectionPreview, onChangeFlowSelectionPreview]);
|
|
2698
3082
|
|
|
2699
3083
|
useEffect(() => {
|
|
@@ -2721,9 +3105,10 @@ export function BookingFlow({
|
|
|
2721
3105
|
|
|
2722
3106
|
/** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
|
|
2723
3107
|
useEffect(() => {
|
|
2724
|
-
if (!
|
|
3108
|
+
if (!isCustomerSelfServeChange) {
|
|
2725
3109
|
setChangeQuoteLoading(false);
|
|
2726
3110
|
setChangeQuoteFetchError(null);
|
|
3111
|
+
setLatestChangeQuote(null);
|
|
2727
3112
|
return;
|
|
2728
3113
|
}
|
|
2729
3114
|
|
|
@@ -2803,7 +3188,7 @@ export function BookingFlow({
|
|
|
2803
3188
|
window.clearTimeout(timer);
|
|
2804
3189
|
};
|
|
2805
3190
|
}, [
|
|
2806
|
-
|
|
3191
|
+
isCustomerSelfServeChange,
|
|
2807
3192
|
hasChangeSelection,
|
|
2808
3193
|
selectedAvailability,
|
|
2809
3194
|
selectedAvailability?.dateTime,
|
|
@@ -3313,31 +3698,33 @@ export function BookingFlow({
|
|
|
3313
3698
|
setError('Removing return option in self-serve is not available. Please contact support.');
|
|
3314
3699
|
return;
|
|
3315
3700
|
}
|
|
3316
|
-
|
|
3317
|
-
// Validate email (required)
|
|
3318
|
-
if (!email) {
|
|
3319
|
-
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
3320
|
-
return;
|
|
3321
|
-
}
|
|
3322
|
-
|
|
3323
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
3324
|
-
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
3325
|
-
return;
|
|
3326
|
-
}
|
|
3327
3701
|
|
|
3328
|
-
|
|
3329
|
-
if (!
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3702
|
+
const skipContactFields = isProviderDashboardChange && isChangeFlow;
|
|
3703
|
+
if (!skipContactFields) {
|
|
3704
|
+
// Validate email (required)
|
|
3705
|
+
if (!email) {
|
|
3706
|
+
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
3707
|
+
return;
|
|
3708
|
+
}
|
|
3333
3709
|
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3710
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
3711
|
+
setError(t('booking.invalidEmail') || 'Please enter a valid email address');
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// Validate first name (required)
|
|
3716
|
+
if (!firstName?.trim()) {
|
|
3717
|
+
setError(t('booking.enterFirstName') || 'Please enter your first name');
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
// Validate last name (required for manage booking lookup)
|
|
3722
|
+
if (!lastName?.trim()) {
|
|
3723
|
+
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3338
3726
|
}
|
|
3339
|
-
|
|
3340
|
-
|
|
3727
|
+
|
|
3341
3728
|
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
3342
3729
|
if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
|
|
3343
3730
|
setError(t('booking.selectPickupLocation'));
|
|
@@ -3363,6 +3750,39 @@ export function BookingFlow({
|
|
|
3363
3750
|
return;
|
|
3364
3751
|
}
|
|
3365
3752
|
|
|
3753
|
+
if (onChangeBooking && isChangeFlow) {
|
|
3754
|
+
const pickupForChange = pickupLocationId
|
|
3755
|
+
? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
|
|
3756
|
+
: null;
|
|
3757
|
+
await onChangeBooking({
|
|
3758
|
+
productId: availabilityProductOptionId,
|
|
3759
|
+
dateTime: selectedAvailability.dateTime,
|
|
3760
|
+
bookingItems,
|
|
3761
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
3762
|
+
pickupLocationId: pickupLocationId ?? null,
|
|
3763
|
+
travelerHotel: pickupForChange?.name ?? null,
|
|
3764
|
+
startTime: selectedAvailability.dateTime ?? null,
|
|
3765
|
+
passengerCount: null,
|
|
3766
|
+
childSafetySeatsCount: null,
|
|
3767
|
+
foodRestrictions: null,
|
|
3768
|
+
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
3769
|
+
cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
|
|
3770
|
+
promoCode: appliedPromoCode ?? null,
|
|
3771
|
+
newTotalAmount: displayChangeFlowProposedTotal,
|
|
3772
|
+
additionalHoursCount: null,
|
|
3773
|
+
pricingAdjustment:
|
|
3774
|
+
providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
|
|
3775
|
+
? {
|
|
3776
|
+
mode: 'MANUAL_LINES',
|
|
3777
|
+
lineOverrides: providerPricingOverrides,
|
|
3778
|
+
additionalAdjustments: providerAdditionalAdjustments,
|
|
3779
|
+
}
|
|
3780
|
+
: undefined,
|
|
3781
|
+
});
|
|
3782
|
+
setLoading(false);
|
|
3783
|
+
return;
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3366
3786
|
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
3367
3787
|
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
3368
3788
|
product.productId,
|
|
@@ -3380,7 +3800,7 @@ export function BookingFlow({
|
|
|
3380
3800
|
let changeIntentIdForCheckout: string | undefined;
|
|
3381
3801
|
let changeBookingReferenceForPaidFlow: string | undefined;
|
|
3382
3802
|
|
|
3383
|
-
if (
|
|
3803
|
+
if (isCustomerSelfServeChange) {
|
|
3384
3804
|
const changeBookingReference = initialValues?.bookingReference?.trim();
|
|
3385
3805
|
const changeLastName = lastName.trim();
|
|
3386
3806
|
if (!changeBookingReference || !changeLastName) {
|
|
@@ -3526,7 +3946,7 @@ export function BookingFlow({
|
|
|
3526
3946
|
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
3527
3947
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
3528
3948
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
3529
|
-
const amountDueForCheckout =
|
|
3949
|
+
const amountDueForCheckout = isCustomerSelfServeChange
|
|
3530
3950
|
? Math.max(0, changeFlowProposedTotal - (originalReceipt?.total ?? 0))
|
|
3531
3951
|
: totalPrice;
|
|
3532
3952
|
const lines = [
|
|
@@ -3648,7 +4068,7 @@ export function BookingFlow({
|
|
|
3648
4068
|
return;
|
|
3649
4069
|
}
|
|
3650
4070
|
|
|
3651
|
-
const paymentIntent =
|
|
4071
|
+
const paymentIntent = isCustomerSelfServeChange
|
|
3652
4072
|
? await createChangeBookingPaymentIntent(
|
|
3653
4073
|
(() => {
|
|
3654
4074
|
const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
|
|
@@ -3785,7 +4205,7 @@ export function BookingFlow({
|
|
|
3785
4205
|
// Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
|
|
3786
4206
|
// /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
|
|
3787
4207
|
successUrlOverride:
|
|
3788
|
-
|
|
4208
|
+
isCustomerSelfServeChange && changeBookingReferenceForPaidFlow
|
|
3789
4209
|
? (() => {
|
|
3790
4210
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
3791
4211
|
const ref = encodeURIComponent(
|
|
@@ -3813,7 +4233,7 @@ export function BookingFlow({
|
|
|
3813
4233
|
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
3814
4234
|
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
3815
4235
|
changeTotals:
|
|
3816
|
-
|
|
4236
|
+
isCustomerSelfServeChange && originalReceipt
|
|
3817
4237
|
? {
|
|
3818
4238
|
previousTotal: originalReceipt.total,
|
|
3819
4239
|
newTotal: totalPrice,
|
|
@@ -4109,7 +4529,7 @@ export function BookingFlow({
|
|
|
4109
4529
|
currency={currency}
|
|
4110
4530
|
showCapacity={isAdmin}
|
|
4111
4531
|
extraDiscountPercent={calendarDiscountPercent}
|
|
4112
|
-
capDiscountToSelectedDate={isChangeFlow &&
|
|
4532
|
+
capDiscountToSelectedDate={isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0}
|
|
4113
4533
|
/>
|
|
4114
4534
|
</div>
|
|
4115
4535
|
</div>
|
|
@@ -4235,7 +4655,7 @@ export function BookingFlow({
|
|
|
4235
4655
|
t={t}
|
|
4236
4656
|
onQuantityChange={handleQuantityChange}
|
|
4237
4657
|
minimumQuantities={changeBookingMinimumQuantities}
|
|
4238
|
-
ticketUnitFloorByCategory={isChangeFlow ?
|
|
4658
|
+
ticketUnitFloorByCategory={isChangeFlow ? changeFlowTicketBookedUnitPriceByCategory : undefined}
|
|
4239
4659
|
/>
|
|
4240
4660
|
)}
|
|
4241
4661
|
|
|
@@ -4247,48 +4667,71 @@ export function BookingFlow({
|
|
|
4247
4667
|
currency={currency}
|
|
4248
4668
|
locale={locale}
|
|
4249
4669
|
onSelectionsChange={updateAddOnSelections}
|
|
4250
|
-
minimumTotalByAddOnId={
|
|
4670
|
+
minimumTotalByAddOnId={isCustomerSelfServeChange ? initialAddOnMinTotalByAddOnId : undefined}
|
|
4251
4671
|
/>
|
|
4252
4672
|
)}
|
|
4253
4673
|
|
|
4254
4674
|
{/* Total and Checkout — shared PriceSummary component */}
|
|
4255
4675
|
{selectedAvailability && (
|
|
4256
4676
|
<CheckoutForm
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4677
|
+
priceSummaryLines={checkoutPriceSummaryLines}
|
|
4678
|
+
totalPrice={changeFlowAmountDue}
|
|
4679
|
+
totalSummaryLabel={
|
|
4680
|
+
isChangeFlow
|
|
4681
|
+
? (t('booking.totalOwedForBookingChange') &&
|
|
4682
|
+
t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
|
|
4683
|
+
? t('booking.totalOwedForBookingChange')
|
|
4684
|
+
: 'Total owed for booking difference')
|
|
4685
|
+
: undefined
|
|
4686
|
+
}
|
|
4687
|
+
subtotal={
|
|
4688
|
+
isChangeFlow
|
|
4689
|
+
? displayChangeFlowSubtotal
|
|
4690
|
+
: (subtotal !== totalFromSummary || effectivePromoDiscountAmount > 0 || addOnTotal > 0
|
|
4691
|
+
? effectiveSubtotal
|
|
4692
|
+
: undefined)
|
|
4693
|
+
}
|
|
4694
|
+
taxAmount={
|
|
4695
|
+
!isTaxIncludedInPrice &&
|
|
4696
|
+
(isChangeFlow ? displayChangeFlowTax : (effectivePromoDiscountAmount > 0 ? effectiveTax : tax)) > 0
|
|
4697
|
+
? (isChangeFlow ? displayChangeFlowTax : (effectivePromoDiscountAmount > 0 ? effectiveTax : tax))
|
|
4698
|
+
: 0
|
|
4699
|
+
}
|
|
4700
|
+
taxRate={pricingConfig?.taxRate}
|
|
4701
|
+
currency={currency}
|
|
4702
|
+
locale={locale}
|
|
4703
|
+
t={t}
|
|
4704
|
+
extraBetweenTaxAndTotal={
|
|
4705
|
+
<>
|
|
4706
|
+
{showProviderPricingInlineEditor && providerPricingUi?.error ? (
|
|
4707
|
+
<div className="mt-2 text-sm text-red-700">{providerPricingUi.error}</div>
|
|
4708
|
+
) : null}
|
|
4709
|
+
{showProviderPricingInlineEditor &&
|
|
4710
|
+
providerPricingUi?.loading &&
|
|
4711
|
+
providerQuotedLines.length === 0 ? (
|
|
4712
|
+
<div className="mt-2 text-sm text-stone-500">Loading price lines...</div>
|
|
4713
|
+
) : null}
|
|
4714
|
+
{showProviderPricingInlineEditor &&
|
|
4715
|
+
providerPricingUi?.helperText &&
|
|
4716
|
+
!providerPricingUi.error ? (
|
|
4717
|
+
<div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
|
|
4718
|
+
) : null}
|
|
4719
|
+
{isChangeFlow && lockedPromoCode ? (
|
|
4720
|
+
<PromoCodeInput
|
|
4721
|
+
promoCodeInput={promoCodeInput}
|
|
4722
|
+
appliedPromoCode={appliedPromoCode}
|
|
4723
|
+
promoCodeError={promoCodeError}
|
|
4724
|
+
promoCodeValidating={promoCodeValidating}
|
|
4725
|
+
promoDiscountAmount={effectivePromoDiscountAmount}
|
|
4726
|
+
currency={currency}
|
|
4727
|
+
locale={locale}
|
|
4728
|
+
t={t}
|
|
4729
|
+
onInputChange={() => {}}
|
|
4730
|
+
onApply={() => {}}
|
|
4731
|
+
onRemove={() => {}}
|
|
4732
|
+
locked
|
|
4733
|
+
/>
|
|
4734
|
+
) : !isChangeFlow ? (
|
|
4292
4735
|
<PromoCodeInput
|
|
4293
4736
|
promoCodeInput={promoCodeInput}
|
|
4294
4737
|
appliedPromoCode={appliedPromoCode}
|
|
@@ -4313,71 +4756,150 @@ export function BookingFlow({
|
|
|
4313
4756
|
fetchedRangesRef.current = [];
|
|
4314
4757
|
}}
|
|
4315
4758
|
/>
|
|
4316
|
-
) : null
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4759
|
+
) : null}
|
|
4760
|
+
</>
|
|
4761
|
+
}
|
|
4762
|
+
extraBeforeSubtotal={
|
|
4763
|
+
showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
|
|
4764
|
+
<div className="space-y-1">
|
|
4765
|
+
{providerPricingUi?.additionalAdjustments?.map((adj) => (
|
|
4766
|
+
<div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
|
|
4767
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
4768
|
+
<button
|
|
4769
|
+
type="button"
|
|
4770
|
+
className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
|
|
4771
|
+
onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
|
|
4772
|
+
>
|
|
4773
|
+
-
|
|
4774
|
+
</button>
|
|
4775
|
+
<input
|
|
4776
|
+
type="text"
|
|
4777
|
+
className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
|
|
4778
|
+
placeholder="Line description"
|
|
4779
|
+
value={adj.label}
|
|
4780
|
+
onChange={(e) =>
|
|
4781
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
|
|
4782
|
+
}
|
|
4783
|
+
/>
|
|
4784
|
+
</div>
|
|
4785
|
+
<div className="flex items-center gap-1">
|
|
4786
|
+
<select
|
|
4787
|
+
className="rounded border border-stone-300 px-1 py-0.5 text-xs"
|
|
4788
|
+
value={adj.mode}
|
|
4789
|
+
onChange={(e) =>
|
|
4790
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
4791
|
+
mode: e.target.value as 'DISCOUNT' | 'CHARGE',
|
|
4792
|
+
})
|
|
4793
|
+
}
|
|
4794
|
+
>
|
|
4795
|
+
<option value="DISCOUNT">-</option>
|
|
4796
|
+
<option value="CHARGE">+</option>
|
|
4797
|
+
</select>
|
|
4798
|
+
<input
|
|
4799
|
+
type="text"
|
|
4800
|
+
inputMode="decimal"
|
|
4801
|
+
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"
|
|
4802
|
+
placeholder="0.00"
|
|
4803
|
+
value={adj.amountInput}
|
|
4804
|
+
onChange={(e) =>
|
|
4805
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
4806
|
+
amountInput: e.target.value,
|
|
4807
|
+
})
|
|
4808
|
+
}
|
|
4809
|
+
/>
|
|
4810
|
+
</div>
|
|
4811
|
+
</div>
|
|
4812
|
+
))}
|
|
4813
|
+
<button
|
|
4814
|
+
type="button"
|
|
4815
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
4816
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
4817
|
+
>
|
|
4818
|
+
+ add line item
|
|
4819
|
+
</button>
|
|
4820
|
+
</div>
|
|
4821
|
+
) : showProviderPricingInlineEditor ? (
|
|
4822
|
+
<button
|
|
4823
|
+
type="button"
|
|
4824
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
4825
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
4826
|
+
>
|
|
4827
|
+
+ add line item
|
|
4828
|
+
</button>
|
|
4829
|
+
) : undefined
|
|
4830
|
+
}
|
|
4831
|
+
firstName={firstName}
|
|
4832
|
+
lastName={lastName}
|
|
4833
|
+
email={email}
|
|
4834
|
+
onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
|
|
4835
|
+
onLastNameChange={(v) => { setLastName(v); setError(''); }}
|
|
4836
|
+
onEmailChange={(v) => { setEmail(v); setError(''); }}
|
|
4837
|
+
readOnlyContactFields={isChangeFlow}
|
|
4838
|
+
pickupLocations={
|
|
4839
|
+
selectedDate && product.pickupLocations && product.pickupLocations.length > 0
|
|
4840
|
+
? product.pickupLocations
|
|
4841
|
+
: undefined
|
|
4842
|
+
}
|
|
4843
|
+
destinations={product.destinations}
|
|
4844
|
+
pickupLocationId={pickupLocationId}
|
|
4845
|
+
pickupLocationSkipped={pickupLocationSkipped}
|
|
4846
|
+
selectedPickupLocation={selectedPickupLocation}
|
|
4847
|
+
highlightedPickupLocationIds={highlightedPickupLocationIds}
|
|
4848
|
+
onLocationSelect={(locationId) => {
|
|
4849
|
+
setPickupLocationId(locationId);
|
|
4850
|
+
setError('');
|
|
4851
|
+
if (locationId === null && pickupLocationSkipped) {
|
|
4852
|
+
setPickupLocationSkipped(false);
|
|
4853
|
+
} else if (locationId !== null) {
|
|
4854
|
+
setPickupLocationSkipped(false);
|
|
4855
|
+
}
|
|
4856
|
+
}}
|
|
4857
|
+
onSkip={() => {
|
|
4858
|
+
setPickupLocationSkipped(true);
|
|
4859
|
+
setPickupLocationId(null);
|
|
4860
|
+
setError('');
|
|
4861
|
+
}}
|
|
4862
|
+
onChangePickup={() => {
|
|
4863
|
+
setPickupLocationId(null);
|
|
4343
4864
|
setPickupLocationSkipped(false);
|
|
4865
|
+
}}
|
|
4866
|
+
termsAccepted={termsAccepted}
|
|
4867
|
+
onTermsChange={(checked) => {
|
|
4868
|
+
setTermsAccepted(checked);
|
|
4869
|
+
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
4870
|
+
}}
|
|
4871
|
+
isAdmin={isAdmin}
|
|
4872
|
+
showCommunicationAdminSection={!isChangeFlow}
|
|
4873
|
+
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
4874
|
+
disableAutoCommunications={disableAutoCommunications}
|
|
4875
|
+
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
4876
|
+
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
4877
|
+
error={checkoutFormError}
|
|
4878
|
+
loading={loading}
|
|
4879
|
+
totalQuantity={totalQuantity}
|
|
4880
|
+
onCheckout={handleCheckout}
|
|
4881
|
+
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
4882
|
+
hideSubmitButton={
|
|
4883
|
+
showCheckoutModal ||
|
|
4884
|
+
showAdminPaymentChoice ||
|
|
4885
|
+
(isChangeFlow && (!hasEffectiveChangeSelection || isChangeQuoteBlocked))
|
|
4344
4886
|
}
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
4362
|
-
disableAutoCommunications={disableAutoCommunications}
|
|
4363
|
-
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
4364
|
-
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
4365
|
-
error={checkoutFormError}
|
|
4366
|
-
loading={loading}
|
|
4367
|
-
totalQuantity={totalQuantity}
|
|
4368
|
-
onCheckout={handleCheckout}
|
|
4369
|
-
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
4370
|
-
hideSubmitButton={
|
|
4371
|
-
showCheckoutModal ||
|
|
4372
|
-
showAdminPaymentChoice ||
|
|
4373
|
-
(isChangeFlow && (!hasChangeSelection || isChangeQuoteBlocked))
|
|
4374
|
-
}
|
|
4375
|
-
submitDisabled={changeFlowSubmitDisabled}
|
|
4376
|
-
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
4377
|
-
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
4378
|
-
attributionConfirmed={partnerAttributionConfirmed}
|
|
4379
|
-
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
4380
|
-
/>
|
|
4887
|
+
submitDisabled={changeFlowSubmitDisabled}
|
|
4888
|
+
attributionSummary={flowUi?.partnerAttributionSummary}
|
|
4889
|
+
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
4890
|
+
attributionConfirmed={partnerAttributionConfirmed}
|
|
4891
|
+
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
4892
|
+
lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
|
|
4893
|
+
onLineAmountInputChange={
|
|
4894
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
|
|
4895
|
+
}
|
|
4896
|
+
onLineAmountInputBlur={
|
|
4897
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
|
|
4898
|
+
}
|
|
4899
|
+
onLineAmountReset={
|
|
4900
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
4901
|
+
}
|
|
4902
|
+
/>
|
|
4381
4903
|
)}
|
|
4382
4904
|
</div>
|
|
4383
4905
|
</>
|