@ticketboothapp/booking 1.2.56 → 1.2.59

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