@ticketboothapp/booking 1.2.56 → 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);
@@ -739,6 +766,8 @@ export function BookingFlow({
739
766
  const hasAutoSelectedPartnerPickupRef = useRef(false);
740
767
  const handleDateSelectRef = useRef<(date: string) => void>(() => {});
741
768
  const isChangeFlow = mode === 'change';
769
+ const isProviderDashboardChange = Boolean(onChangeBooking);
770
+ const isCustomerSelfServeChange = isChangeFlow && !isProviderDashboardChange;
742
771
 
743
772
  useEffect(() => {
744
773
  setPartnerAttributionConfirmed(false);
@@ -748,12 +777,14 @@ export function BookingFlow({
748
777
  * user picks a different return time — baseline is the first auto-selected return for this outbound.
749
778
  */
750
779
  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). */
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. */
755
786
  const changeBookingMinimumQuantities = useMemo(() => {
756
- if (!isChangeFlow || !initialValues?.bookingItems?.length) return undefined;
787
+ if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
757
788
  const m: Record<string, number> = {};
758
789
  for (const item of initialValues.bookingItems) {
759
790
  const key = item.category?.trim();
@@ -761,7 +792,7 @@ export function BookingFlow({
761
792
  m[key] = Math.max(0, Number(item.count) || 0);
762
793
  }
763
794
  return m;
764
- }, [isChangeFlow, initialValues?.bookingItems]);
795
+ }, [isCustomerSelfServeChange, initialValues?.bookingItems]);
765
796
  const [adminChoiceData, setAdminChoiceData] = useState<{
766
797
  reservationReference: string;
767
798
  reservationExpiration?: string;
@@ -1671,27 +1702,27 @@ export function BookingFlow({
1671
1702
 
1672
1703
  const initialAddOnMinQtyByKey = useMemo(() => {
1673
1704
  const map = new Map<string, number>();
1674
- if (!isChangeFlow) return map;
1705
+ if (!isCustomerSelfServeChange) return map;
1675
1706
  for (const sel of initialAddOnBaselineSelections) {
1676
1707
  const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
1677
1708
  map.set(key, (map.get(key) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
1678
1709
  }
1679
1710
  return map;
1680
- }, [isChangeFlow, initialAddOnBaselineSelections]);
1711
+ }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
1681
1712
 
1682
1713
  const initialAddOnMinTotalByAddOnId = useMemo(() => {
1683
1714
  const map = new Map<string, number>();
1684
- if (!isChangeFlow) return map;
1715
+ if (!isCustomerSelfServeChange) return map;
1685
1716
  for (const sel of initialAddOnBaselineSelections) {
1686
1717
  const addOnId = sel.addOnId.trim();
1687
1718
  map.set(addOnId, (map.get(addOnId) ?? 0) + Math.max(1, Number(sel.quantity) || 1));
1688
1719
  }
1689
1720
  return map;
1690
- }, [isChangeFlow, initialAddOnBaselineSelections]);
1721
+ }, [isCustomerSelfServeChange, initialAddOnBaselineSelections]);
1691
1722
 
1692
1723
  const applyChangeFlowAddOnFloor = useCallback(
1693
1724
  (nextSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>) => {
1694
- if (!isChangeFlow || initialAddOnMinQtyByKey.size === 0) return nextSelections;
1725
+ if (!isCustomerSelfServeChange || initialAddOnMinQtyByKey.size === 0) return nextSelections;
1695
1726
  const qtyByKey = new Map<string, number>();
1696
1727
  for (const sel of nextSelections) {
1697
1728
  const key = `${sel.addOnId.trim()}::${sel.variantId?.trim() || ''}`;
@@ -1718,7 +1749,7 @@ export function BookingFlow({
1718
1749
  return { addOnId, variantId, quantity: qty };
1719
1750
  });
1720
1751
  },
1721
- [isChangeFlow, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
1752
+ [isCustomerSelfServeChange, initialAddOnMinQtyByKey, initialAddOnMinTotalByAddOnId]
1722
1753
  );
1723
1754
 
1724
1755
  const updateAddOnSelections = useCallback(
@@ -2042,7 +2073,7 @@ export function BookingFlow({
2042
2073
  [pricingConfig?.fees]
2043
2074
  );
2044
2075
 
2045
- const changeFlowTicketPriceFloorByCategory = useMemo(() => {
2076
+ const changeFlowTicketBookedUnitPriceByCategory = useMemo(() => {
2046
2077
  if (!isChangeFlow || !originalReceipt?.lineItems?.length) return new Map<string, number>();
2047
2078
  const amountByCategory = new Map<string, number>();
2048
2079
  const qtyByCategory = new Map<string, number>();
@@ -2083,6 +2114,25 @@ export function BookingFlow({
2083
2114
  if (totalAmount <= 0 || totalQty <= 0) return null;
2084
2115
  return totalAmount / totalQty;
2085
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]);
2086
2136
 
2087
2137
  const returnOptionsWithFloor = useMemo(() => {
2088
2138
  const options = selectedAvailability?.returnOptions ?? [];
@@ -2156,13 +2206,11 @@ export function BookingFlow({
2156
2206
  const backendInDisplayCurrency = rate.priceByCurrency?.[currency] ?? (currency === 'CAD' ? backendPriceCAD : 0);
2157
2207
  const baseInDisplayCurrency = getBaseInDisplayCurrency(rate.category);
2158
2208
  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
2209
  return {
2162
2210
  category: rate.category,
2163
2211
  rateId: rate.rateId || rate.category,
2164
2212
  available: rate.available,
2165
- price,
2213
+ price: built.price,
2166
2214
  priceCAD: built.priceCAD,
2167
2215
  baseInDisplayCurrency: built.baseInDisplayCurrency,
2168
2216
  appliedAdjustments: built.appliedAdjustments,
@@ -2172,19 +2220,17 @@ export function BookingFlow({
2172
2220
  const baseInDisplayCurrency = getBaseInDisplayCurrency(p.category);
2173
2221
  const backendInDisplayCurrency = currency === 'CAD' ? priceCADFromApi : 0;
2174
2222
  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
2223
  return {
2178
2224
  category: p.category,
2179
2225
  rateId: p.category,
2180
2226
  available: selectedAvailability.vacancies,
2181
- price,
2227
+ price: built.price,
2182
2228
  priceCAD: built.priceCAD,
2183
2229
  baseInDisplayCurrency: built.baseInDisplayCurrency,
2184
2230
  appliedAdjustments: built.appliedAdjustments,
2185
2231
  };
2186
2232
  }) || [];
2187
- }, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView, changeFlowTicketPriceFloorByCategory]);
2233
+ }, [selectedAvailability, currency, hasFees, pricingConfig, precomputedPricesByOption, activeOptions, isSimplifiedPricingView]);
2188
2234
 
2189
2235
  // Price breakdown: mid-layer returns line items (base + one per rule/deal). UI renders each line; rate in brackets when used.
2190
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 => {
@@ -2220,6 +2266,51 @@ export function BookingFlow({
2220
2266
  [quantities, pricing, selectedReturnOptionWithFloor, pricingConfig, currency, hasFees, cancellationPolicyId]
2221
2267
  );
2222
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
+ ]);
2223
2314
  /** Round-trip party limit: both legs must fit — use the tighter of outbound vs return vacancies. */
2224
2315
  const effectivePartySizeCap = useMemo(() => {
2225
2316
  if (!selectedAvailability) return 0;
@@ -2283,7 +2374,63 @@ export function BookingFlow({
2283
2374
  }, [addOnSelections, addOns]);
2284
2375
 
2285
2376
  // Effective subtotal includes add-ons (for promo discount and total)
2286
- 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;
2287
2434
 
2288
2435
  /** Stable signature for promo discount API (avoid effect re-fire on object identity churn). */
2289
2436
  const quantitiesSignature = useMemo(
@@ -2315,6 +2462,30 @@ export function BookingFlow({
2315
2462
  return [...feeLineItems, ...addOnLines];
2316
2463
  }, [feeLineItems, addOnSelections, addOns]);
2317
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
+
2318
2489
  const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
2319
2490
  if (!selectedAvailability) return [];
2320
2491
  return [
@@ -2328,6 +2499,13 @@ export function BookingFlow({
2328
2499
  );
2329
2500
  return {
2330
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)),
2331
2509
  category: line.category,
2332
2510
  qty: line.qty,
2333
2511
  itemTotal: line.itemTotal,
@@ -2362,6 +2540,25 @@ export function BookingFlow({
2362
2540
  fee.name.toLowerCase().includes('license'));
2363
2541
  return {
2364
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
+ ),
2365
2562
  label: feeLineItems.some((f) => f.name === fee.name)
2366
2563
  ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
2367
2564
  : fee.name,
@@ -2388,6 +2585,8 @@ export function BookingFlow({
2388
2585
  effectiveCancellationPolicyLabel,
2389
2586
  feeLineItemsWithAddOns,
2390
2587
  feeLineItems,
2588
+ showProviderPricingInlineEditor,
2589
+ providerEditableLineByNormalizedLabel,
2391
2590
  ]);
2392
2591
 
2393
2592
  // Promo discount from backend (order-level only; rates are pre-promo)
@@ -2519,10 +2718,30 @@ export function BookingFlow({
2519
2718
  : totalPrice;
2520
2719
  const changeSelectionDetails = useMemo(() => {
2521
2720
  if (!isChangeFlow || !initialValues) {
2522
- 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
+ };
2523
2732
  }
2524
2733
  if (!selectedAvailability) {
2525
- 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
+ };
2526
2745
  }
2527
2746
  const initialMs = initialValues.dateTime ? parseAvailabilityDateTime(initialValues.dateTime).getTime() : null;
2528
2747
  const selectedMs = parseAvailabilityDateTime(selectedAvailability.dateTime).getTime();
@@ -2534,7 +2753,13 @@ export function BookingFlow({
2534
2753
  const optionChanged = Boolean(
2535
2754
  initialOpt && selectedOpt && initialOpt !== selectedOpt
2536
2755
  );
2537
- 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);
2538
2763
  const normalizeCounts = (items: Array<{ category: string; count: number }> | null | undefined) => {
2539
2764
  const map = new Map<string, number>();
2540
2765
  for (const item of items ?? []) {
@@ -2580,7 +2805,16 @@ export function BookingFlow({
2580
2805
  let returnChanged = false;
2581
2806
  if (hasReturnOptions && selectedReturnOption) {
2582
2807
  if (initialReturnId && selectedReturnId) {
2583
- 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
+ }
2584
2818
  } else if (initialReturnDt && selectedReturnDt) {
2585
2819
  returnChanged =
2586
2820
  parseAvailabilityDateTime(initialReturnDt).getTime() !==
@@ -2597,9 +2831,22 @@ export function BookingFlow({
2597
2831
  countsChanged ||
2598
2832
  addOnsChanged ||
2599
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,
2600
2842
  dateChanged,
2601
2843
  // Tickets line corresponds to "option + ticket counts"; add-ons and pickup changes affect itinerary but not the ticket label.
2602
2844
  ticketsChanged: Boolean(optionChanged || countsChanged),
2845
+ optionChanged,
2846
+ pickupChanged,
2847
+ countsChanged,
2848
+ addOnsChanged,
2849
+ returnChanged,
2603
2850
  };
2604
2851
  }, [
2605
2852
  isChangeFlow,
@@ -2612,25 +2859,72 @@ export function BookingFlow({
2612
2859
  addOnSelections,
2613
2860
  initialAddOnMinQtyByKey,
2614
2861
  ]);
2615
- const hasChangeSelection = changeSelectionDetails.hasChangesFromInitial;
2862
+ const hasChangeSelection =
2863
+ isProviderDashboardChange
2864
+ ? changeSelectionDetails.hasOperationalChangesFromInitial
2865
+ : changeSelectionDetails.hasChangesFromInitial;
2616
2866
 
2617
2867
  const changeFlowNeedsServerPrice =
2618
- isChangeFlow &&
2868
+ isCustomerSelfServeChange &&
2619
2869
  hasChangeSelection &&
2620
2870
  !!initialValues?.bookingReference?.trim() &&
2621
2871
  !!lastName.trim();
2622
2872
 
2623
- const isChangeQuoteBlocked = isChangeFlow && latestChangeQuote?.canProceed === false;
2624
- const requiresReturnInChangeFlow = isChangeFlow && !!initialValues?.returnAvailabilityId?.trim();
2873
+ const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
2874
+ const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
2625
2875
  const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
2626
2876
 
2627
2877
  const changeFlowSubmitDisabled =
2628
2878
  isChangeFlow &&
2629
2879
  (missingRequiredReturnSelection ||
2630
- (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;
2631
2923
 
2632
2924
  const changeFlowClientEstimateDue = originalReceipt
2633
- ? Math.max(changeFlowProposedTotal - originalReceipt.total, 0)
2925
+ ? (isProviderDashboardChange
2926
+ ? displayChangeFlowProposedTotal - originalReceipt.total
2927
+ : Math.max(displayChangeFlowProposedTotal - originalReceipt.total, 0))
2634
2928
  : totalPrice;
2635
2929
 
2636
2930
  /**
@@ -2638,12 +2932,24 @@ export function BookingFlow({
2638
2932
  * Quote is still required before submit (session + canProceed); `clientProposedTotal` on quote keeps BE in sync.
2639
2933
  */
2640
2934
  const changeFlowAmountDueRaw = isChangeFlow ? changeFlowClientEstimateDue : totalPrice;
2641
- const changeFlowAmountDue =
2642
- 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;
2643
2941
 
2644
2942
  const changeCheckoutButtonLabel = (() => {
2645
2943
  if (!isChangeFlow) return undefined;
2646
- 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
+ }
2647
2953
  if (changeFlowNeedsServerPrice) {
2648
2954
  if (changeQuoteLoading) {
2649
2955
  const tr = t('booking.updatingPrice');
@@ -2679,8 +2985,39 @@ export function BookingFlow({
2679
2985
  const checkoutFormError =
2680
2986
  (error || '') ||
2681
2987
  (missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
2682
- (isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
2683
- (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
+ : [];
2684
3021
 
2685
3022
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
2686
3023
  if (!isChangeFlow) return null;
@@ -2718,8 +3055,29 @@ export function BookingFlow({
2718
3055
  ]);
2719
3056
 
2720
3057
  useEffect(() => {
2721
- if (!isChangeFlow || !onChangeFlowSelectionPreview) return;
2722
- 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);
2723
3081
  }, [isChangeFlow, changeFlowSelectionPreview, onChangeFlowSelectionPreview]);
2724
3082
 
2725
3083
  useEffect(() => {
@@ -2747,9 +3105,10 @@ export function BookingFlow({
2747
3105
 
2748
3106
  /** Debounced server quote so CTA + “amount owed” match PaymentIntent; avoids free confirm when FE estimate ≠ BE. */
2749
3107
  useEffect(() => {
2750
- if (!isChangeFlow) {
3108
+ if (!isCustomerSelfServeChange) {
2751
3109
  setChangeQuoteLoading(false);
2752
3110
  setChangeQuoteFetchError(null);
3111
+ setLatestChangeQuote(null);
2753
3112
  return;
2754
3113
  }
2755
3114
 
@@ -2829,7 +3188,7 @@ export function BookingFlow({
2829
3188
  window.clearTimeout(timer);
2830
3189
  };
2831
3190
  }, [
2832
- isChangeFlow,
3191
+ isCustomerSelfServeChange,
2833
3192
  hasChangeSelection,
2834
3193
  selectedAvailability,
2835
3194
  selectedAvailability?.dateTime,
@@ -3339,31 +3698,33 @@ export function BookingFlow({
3339
3698
  setError('Removing return option in self-serve is not available. Please contact support.');
3340
3699
  return;
3341
3700
  }
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
3701
 
3354
- // Validate first name (required)
3355
- if (!firstName?.trim()) {
3356
- setError(t('booking.enterFirstName') || 'Please enter your first name');
3357
- return;
3358
- }
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
+ }
3359
3709
 
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;
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
+ }
3364
3726
  }
3365
-
3366
-
3727
+
3367
3728
  // Allow checkout if pickup location is selected OR if user chose "I don't know"
3368
3729
  if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
3369
3730
  setError(t('booking.selectPickupLocation'));
@@ -3389,6 +3750,39 @@ export function BookingFlow({
3389
3750
  return;
3390
3751
  }
3391
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
+
3392
3786
  const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
3393
3787
  clientChannelSource: inferClientBookingSourceFromProductIds(
3394
3788
  product.productId,
@@ -3406,7 +3800,7 @@ export function BookingFlow({
3406
3800
  let changeIntentIdForCheckout: string | undefined;
3407
3801
  let changeBookingReferenceForPaidFlow: string | undefined;
3408
3802
 
3409
- if (isChangeFlow) {
3803
+ if (isCustomerSelfServeChange) {
3410
3804
  const changeBookingReference = initialValues?.bookingReference?.trim();
3411
3805
  const changeLastName = lastName.trim();
3412
3806
  if (!changeBookingReference || !changeLastName) {
@@ -3552,7 +3946,7 @@ export function BookingFlow({
3552
3946
  // Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
3553
3947
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
3554
3948
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
3555
- const amountDueForCheckout = isChangeFlow
3949
+ const amountDueForCheckout = isCustomerSelfServeChange
3556
3950
  ? Math.max(0, changeFlowProposedTotal - (originalReceipt?.total ?? 0))
3557
3951
  : totalPrice;
3558
3952
  const lines = [
@@ -3674,7 +4068,7 @@ export function BookingFlow({
3674
4068
  return;
3675
4069
  }
3676
4070
 
3677
- const paymentIntent = isChangeFlow
4071
+ const paymentIntent = isCustomerSelfServeChange
3678
4072
  ? await createChangeBookingPaymentIntent(
3679
4073
  (() => {
3680
4074
  const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
@@ -3811,7 +4205,7 @@ export function BookingFlow({
3811
4205
  // Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
3812
4206
  // /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
3813
4207
  successUrlOverride:
3814
- isChangeFlow && changeBookingReferenceForPaidFlow
4208
+ isCustomerSelfServeChange && changeBookingReferenceForPaidFlow
3815
4209
  ? (() => {
3816
4210
  const origin = typeof window !== 'undefined' ? window.location.origin : '';
3817
4211
  const ref = encodeURIComponent(
@@ -3839,7 +4233,7 @@ export function BookingFlow({
3839
4233
  promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
3840
4234
  discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
3841
4235
  changeTotals:
3842
- isChangeFlow && originalReceipt
4236
+ isCustomerSelfServeChange && originalReceipt
3843
4237
  ? {
3844
4238
  previousTotal: originalReceipt.total,
3845
4239
  newTotal: totalPrice,
@@ -4135,7 +4529,7 @@ export function BookingFlow({
4135
4529
  currency={currency}
4136
4530
  showCapacity={isAdmin}
4137
4531
  extraDiscountPercent={calendarDiscountPercent}
4138
- capDiscountToSelectedDate={isChangeFlow && changeFlowTicketPriceFloorByCategory.size > 0}
4532
+ capDiscountToSelectedDate={isChangeFlow && changeFlowTicketBookedUnitPriceByCategory.size > 0}
4139
4533
  />
4140
4534
  </div>
4141
4535
  </div>
@@ -4261,7 +4655,7 @@ export function BookingFlow({
4261
4655
  t={t}
4262
4656
  onQuantityChange={handleQuantityChange}
4263
4657
  minimumQuantities={changeBookingMinimumQuantities}
4264
- ticketUnitFloorByCategory={isChangeFlow ? changeFlowTicketPriceFloorByCategory : undefined}
4658
+ ticketUnitFloorByCategory={isChangeFlow ? changeFlowTicketBookedUnitPriceByCategory : undefined}
4265
4659
  />
4266
4660
  )}
4267
4661
 
@@ -4273,48 +4667,71 @@ export function BookingFlow({
4273
4667
  currency={currency}
4274
4668
  locale={locale}
4275
4669
  onSelectionsChange={updateAddOnSelections}
4276
- minimumTotalByAddOnId={isChangeFlow ? initialAddOnMinTotalByAddOnId : undefined}
4670
+ minimumTotalByAddOnId={isCustomerSelfServeChange ? initialAddOnMinTotalByAddOnId : undefined}
4277
4671
  />
4278
4672
  )}
4279
4673
 
4280
4674
  {/* Total and Checkout — shared PriceSummary component */}
4281
4675
  {selectedAvailability && (
4282
4676
  <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 ? (
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 ? (
4318
4735
  <PromoCodeInput
4319
4736
  promoCodeInput={promoCodeInput}
4320
4737
  appliedPromoCode={appliedPromoCode}
@@ -4339,71 +4756,150 @@ export function BookingFlow({
4339
4756
  fetchedRangesRef.current = [];
4340
4757
  }}
4341
4758
  />
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) {
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);
4369
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))
4370
4886
  }
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
- />
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
+ />
4407
4903
  )}
4408
4904
  </div>
4409
4905
  </>