@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.
@@ -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
- const lockedPromoCode = isChangeFlow
750
- ? (initialValues?.promoCode?.trim().toUpperCase() || null)
751
- : null;
752
- /** Change booking: cannot reduce tickets below original counts (can still decrease after increasing). */
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 (!isChangeFlow || !initialValues?.bookingItems?.length) return undefined;
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
- }, [isChangeFlow, initialValues?.bookingItems]);
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
- if (!hasPartialCache) setLoadingAvailabilities(true);
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 (!isChangeFlow) return map;
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
- }, [isChangeFlow, initialAddOnBaselineSelections]);
1711
+ }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
1655
1712
 
1656
1713
  const initialAddOnMinTotalByAddOnId = useMemo(() => {
1657
1714
  const map = new Map<string, number>();
1658
- if (!isChangeFlow) return map;
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
- }, [isChangeFlow, initialAddOnBaselineSelections]);
1721
+ }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
1665
1722
 
1666
1723
  const applyChangeFlowAddOnFloor = useCallback(
1667
1724
  (nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => {
1668
- if (!isChangeFlow || initialAddOnMinQtyByKey.size === 0) return nextSelections;
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
- [isChangeFlow, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
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 changeFlowTicketPriceFloorByCategory = useMemo(() => {
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, changeFlowTicketPriceFloorByCategory]);
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 effectiveSubtotal = subtotal + addOnTotal;
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 { hasChangesFromInitial: true, dateChanged: false, ticketsChanged: false };
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 { hasChangesFromInitial: false, dateChanged: false, ticketsChanged: false };
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 pickupChanged = (initialValues.pickupLocationId ?? null) !== (pickupLocationId ?? null);
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
- returnChanged = initialReturnId !== selectedReturnId;
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 = changeSelectionDetails.hasChangesFromInitial;
2862
+ const hasChangeSelection =
2863
+ isProviderDashboardChange
2864
+ ? changeSelectionDetails.hasOperationalChangesFromInitial
2865
+ : changeSelectionDetails.hasChangesFromInitial;
2590
2866
 
2591
2867
  const changeFlowNeedsServerPrice =
2592
- isChangeFlow &&
2868
+ isCustomerSelfServeChange &&
2593
2869
  hasChangeSelection &&
2594
2870
  !!initialValues?.bookingReference?.trim() &&
2595
2871
  !!lastName.trim();
2596
2872
 
2597
- const isChangeQuoteBlocked = isChangeFlow && latestChangeQuote?.canProceed === false;
2598
- const requiresReturnInChangeFlow = isChangeFlow && !!initialValues?.returnAvailabilityId?.trim();
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
- (changeFlowNeedsServerPrice && (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError))));
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
- ? Math.max(changeFlowProposedTotal - originalReceipt.total, 0)
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
- isChangeFlow ? Math.round(changeFlowAmountDueRaw * 100) / 100 : changeFlowAmountDueRaw;
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 (!hasChangeSelection) return undefined;
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 || !onChangeFlowSelectionPreview) return;
2696
- onChangeFlowSelectionPreview(changeFlowSelectionPreview);
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 (!isChangeFlow) {
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
- isChangeFlow,
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
- // Validate first name (required)
3329
- if (!firstName?.trim()) {
3330
- setError(t('booking.enterFirstName') || 'Please enter your first name');
3331
- return;
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
- // Validate last name (required for manage booking lookup)
3335
- if (!lastName?.trim()) {
3336
- setError(t('booking.enterLastName') || 'Please enter your last name');
3337
- return;
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 (isChangeFlow) {
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 = isChangeFlow
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 = isChangeFlow
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
- isChangeFlow && changeBookingReferenceForPaidFlow
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
- isChangeFlow && originalReceipt
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 && changeFlowTicketPriceFloorByCategory.size > 0}
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 ? changeFlowTicketPriceFloorByCategory : undefined}
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={isChangeFlow ? initialAddOnMinTotalByAddOnId : undefined}
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
- priceSummaryLines={checkoutPriceSummaryLines}
4258
- totalPrice={changeFlowAmountDue}
4259
- totalSummaryLabel={
4260
- isChangeFlow
4261
- ? (t('booking.totalOwedForBookingChange') &&
4262
- t('booking.totalOwedForBookingChange') !== 'booking.totalOwedForBookingChange'
4263
- ? t('booking.totalOwedForBookingChange')
4264
- : 'Total owed for booking difference')
4265
- : undefined
4266
- }
4267
- subtotal={subtotal !== totalFromSummary || effectivePromoDiscountAmount > 0 || addOnTotal > 0 ? effectiveSubtotal : undefined}
4268
- taxAmount={!isTaxIncludedInPrice && (effectivePromoDiscountAmount > 0 ? effectiveTax : tax) > 0 ? (effectivePromoDiscountAmount > 0 ? effectiveTax : tax) : 0}
4269
- taxRate={pricingConfig?.taxRate}
4270
- currency={currency}
4271
- locale={locale}
4272
- t={t}
4273
- extraBetweenTaxAndTotal={
4274
- <>
4275
- {isChangeFlow && originalReceipt && lockedPromoCode ? (
4276
- <PromoCodeInput
4277
- promoCodeInput={promoCodeInput}
4278
- appliedPromoCode={appliedPromoCode}
4279
- promoCodeError={promoCodeError}
4280
- promoCodeValidating={promoCodeValidating}
4281
- promoDiscountAmount={effectivePromoDiscountAmount}
4282
- currency={currency}
4283
- locale={locale}
4284
- t={t}
4285
- onInputChange={() => {}}
4286
- onApply={() => {}}
4287
- onRemove={() => {}}
4288
- locked
4289
- />
4290
- ) : (
4291
- !isChangeFlow ? (
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
- firstName={firstName}
4321
- lastName={lastName}
4322
- email={email}
4323
- onFirstNameChange={(v) => { setFirstName(v); setError(''); }}
4324
- onLastNameChange={(v) => { setLastName(v); setError(''); }}
4325
- onEmailChange={(v) => { setEmail(v); setError(''); }}
4326
- readOnlyContactFields={isChangeFlow}
4327
- pickupLocations={
4328
- selectedDate && product.pickupLocations && product.pickupLocations.length > 0
4329
- ? product.pickupLocations
4330
- : undefined
4331
- }
4332
- destinations={product.destinations}
4333
- pickupLocationId={pickupLocationId}
4334
- pickupLocationSkipped={pickupLocationSkipped}
4335
- selectedPickupLocation={selectedPickupLocation}
4336
- highlightedPickupLocationIds={highlightedPickupLocationIds}
4337
- onLocationSelect={(locationId) => {
4338
- setPickupLocationId(locationId);
4339
- setError('');
4340
- if (locationId === null && pickupLocationSkipped) {
4341
- setPickupLocationSkipped(false);
4342
- } else if (locationId !== null) {
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
- onSkip={() => {
4347
- setPickupLocationSkipped(true);
4348
- setPickupLocationId(null);
4349
- setError('');
4350
- }}
4351
- onChangePickup={() => {
4352
- setPickupLocationId(null);
4353
- setPickupLocationSkipped(false);
4354
- }}
4355
- termsAccepted={termsAccepted}
4356
- onTermsChange={(checked) => {
4357
- setTermsAccepted(checked);
4358
- setTermsAcceptedAt(checked ? new Date().toISOString() : null);
4359
- }}
4360
- isAdmin={isAdmin}
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
  </>