@ticketboothapp/booking 1.2.66 → 1.2.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.66",
3
+ "version": "1.2.68",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -7,9 +7,11 @@ import {
7
7
  getAvailabilities,
8
8
  cancelReservation,
9
9
  cancelReservationBestEffort,
10
+ createPaymentIntent,
10
11
  quoteChangeBooking,
11
12
  confirmFreeChangeBooking,
12
13
  createChangeBookingPaymentIntent,
14
+ confirmBookingWithoutPayment,
13
15
  getAddOns,
14
16
  validatePromoCode,
15
17
  getPromoDiscount,
@@ -31,6 +33,7 @@ import {
31
33
  } from '../../lib/booking-constants';
32
34
  import { getSundayOfWeek } from '../../lib/booking/sunday-week';
33
35
  import { Calendar } from './Calendar';
36
+ import { AdminPaymentChoiceModal } from './AdminPaymentChoiceModal';
34
37
  import { ItineraryBox } from './ItineraryBox';
35
38
  import { ItineraryPlaceholder } from './ItineraryPlaceholder';
36
39
  import { PickupTimeSelector } from './PickupTimeSelector';
@@ -96,6 +99,7 @@ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
96
99
  import { MANAGE_BOOKING_FROM_CHANGE_PAYMENT, MANAGE_BOOKING_QUERY_FROM } from '../../lib/manage-booking-post-checkout';
97
100
  import { useBookingHost } from '../../runtime';
98
101
  import type { BookingFlowUiOptions } from './booking-flow-ui';
102
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
99
103
  import type { ChangeBookingFlowProps, ChangeFlowSelectionPreview } from './booking-flow-types';
100
104
  import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provider';
101
105
 
@@ -107,6 +111,8 @@ import { BOOKING_FLOW_ABANDON_EVENT } from '../../providers/booking-dialog-provi
107
111
  * `serverPreview.priceSummaryLines` when the quote includes rows; otherwise falls back to FE-built lines after totals confirm.
108
112
  *
109
113
  * Until the first successful quote, `selfServeCheckoutPlaceholder` still avoids showing unchecked totals.
114
+ *
115
+ * **Provider dashboard** (`onChangeBooking`) — unchanged; manual pricing via `flowUi.providerDashboardChangePricingUi`.
110
116
  */
111
117
  function mergeQuoteSliceWithServerPreview(
112
118
  slice: ReturnType<typeof sliceChangeQuoteForUi>,
@@ -403,8 +409,9 @@ function pickOutboundMatchingPreviousSelection(
403
409
  anchor: { productOptionId: string | null; minutesFromMidnight: number },
404
410
  companyTimezone: string,
405
411
  optionsMap: Map<string, { mostPopular?: boolean }>,
412
+ isAdmin: boolean
406
413
  ): Availability | null {
407
- const selectable = timesForSelectedDate.filter((a) => (a.vacancies ?? 0) > 0);
414
+ const selectable = timesForSelectedDate.filter((a) => (a.vacancies ?? 0) > 0 || isAdmin);
408
415
  if (selectable.length === 0) return null;
409
416
 
410
417
  const withOption =
@@ -438,8 +445,9 @@ function pickReturnMatchingPreviousSelection(
438
445
  sortedReturnOptions: ReturnOption[],
439
446
  anchor: { returnLocation: string; minutesFromMidnight: number },
440
447
  companyTimezone: string,
448
+ isAdmin: boolean
441
449
  ): ReturnOption | null {
442
- const eligible = sortedReturnOptions.filter((o) => (o.vacancies ?? 0) > 0);
450
+ const eligible = sortedReturnOptions.filter((o) => (o.vacancies ?? 0) > 0 || isAdmin);
443
451
  if (eligible.length === 0) return null;
444
452
 
445
453
  const sameLoc = eligible.filter((o) => o.returnLocation === anchor.returnLocation);
@@ -628,7 +636,8 @@ function resolveInitialAvailabilityFromBooking(
628
636
  }
629
637
 
630
638
  /**
631
- * Embed fork (ticketbooth provider dashboard): same self-serve **change booking** as {@link ChangeBookingFlow}.
639
+ * Admin / provider-dashboard **change booking** literal duplicate of {@link ChangeBookingFlow} for now
640
+ * so ticketbooth can diverge without affecting the public site flow.
632
641
  */
633
642
  export function AdminChangeBookingFlow({
634
643
  product,
@@ -652,6 +661,7 @@ export function AdminChangeBookingFlow({
652
661
  partnerPortalBooking = false,
653
662
  availabilityPricingProfileId,
654
663
  availabilityCancellationPolicyProfileId,
664
+ onChangeBooking,
655
665
  }: ChangeBookingFlowProps) {
656
666
  /** Always the booking’s sold currency — not the site currency switcher / parent default. */
657
667
  const currency = useMemo((): Currency => {
@@ -663,6 +673,16 @@ export function AdminChangeBookingFlow({
663
673
  return DEFAULT_CURRENCY;
664
674
  }, [originalReceipt?.currency, initialValues?.currency, currencyFromParent]);
665
675
 
676
+ const isManualOverrideEligibleLine = (line: { editable: boolean; type?: string; label?: string }): boolean => {
677
+ if (!line.editable) return false;
678
+ const type = (line.type ?? '').toUpperCase();
679
+ const label = (line.label ?? '').toLowerCase();
680
+ const isPromoLikeType =
681
+ type.includes('PROMO') || type.includes('DISCOUNT') || type.includes('VOUCHER') || type.includes('GIFT');
682
+ const isPromoLikeLabel =
683
+ label.includes('promo') || label.includes('discount') || label.includes('voucher') || label.includes('gift');
684
+ return !(isPromoLikeType || isPromoLikeLabel);
685
+ };
666
686
  const { env, analytics } = useBookingHost();
667
687
  const { t } = useTranslations();
668
688
  const { locale } = useLocale();
@@ -671,6 +691,7 @@ export function AdminChangeBookingFlow({
671
691
  const cancellationPolicyProfileIdForAvailabilities =
672
692
  (availabilityCancellationPolicyProfileId ?? '').trim() || null;
673
693
  const {
694
+ permissions,
674
695
  isSimplifiedPricingView,
675
696
  onShowManage,
676
697
  getSuccessUrl,
@@ -678,6 +699,7 @@ export function AdminChangeBookingFlow({
678
699
  mode: bookingAppMode,
679
700
  } = useBookingApp();
680
701
  const availabilitiesCache = useAvailabilitiesCache();
702
+ const isAdmin = permissions.viewerRole === 'admin';
681
703
  const [availabilities, setAvailabilities] = useState<Availability[]>([]);
682
704
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
683
705
  const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
@@ -776,6 +798,12 @@ export function AdminChangeBookingFlow({
776
798
  differenceTotal: number;
777
799
  };
778
800
  } | null>(null);
801
+ /** Admin only: skip sending confirmation at creation (provider dashboard). */
802
+ const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
803
+ /** Admin only: disable all auto communications for this booking (provider dashboard). */
804
+ const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
805
+ /** Admin only: show choice to pay now or confirm without payment (full balance owed). */
806
+ const [showAdminPaymentChoice, setShowAdminPaymentChoice] = useState(false);
779
807
  const hasAppliedInitialValuesRef = useRef(false);
780
808
  const hasAppliedInitialQuantitiesRef = useRef(false);
781
809
  const hasHydratedAddOnsFromReceiptRef = useRef(false);
@@ -794,11 +822,23 @@ export function AdminChangeBookingFlow({
794
822
  return null;
795
823
  }
796
824
  }, [initialValues?.dateTime, companyTimezone]);
825
+ const isProviderDashboardChange = Boolean(onChangeBooking);
826
+ /** Any change from an existing booking (public or provider). */
827
+ const isChangeBookingContext = Boolean(initialValues?.bookingReference?.trim());
828
+ /**
829
+ * When the provider passes `onChangeBooking` we normally apply the change immediately on checkout.
830
+ * In provider-dashboard + admin + change mode we need the "Pay now / confirm without payment" step
831
+ * first, so we run the public change-intent quote + PI, then call `onChangeBooking` from
832
+ * `handleConfirmWithoutPayment` (or after paying in the checkout modal).
833
+ */
834
+ const deferProviderApplyToAdminPaymentChoice =
835
+ isAdmin &&
836
+ isChangeBookingContext &&
837
+ bookingAppMode === 'provider-dashboard' &&
838
+ isProviderDashboardChange;
839
+ const isCustomerSelfServeChange = !isProviderDashboardChange || deferProviderApplyToAdminPaymentChoice;
797
840
  /** Do not render catalog-/FE-derived dollar amounts in UI until `quoteChangeBooking` returns `serverDisplay`. */
798
- const suppressSelfServeCurrencyUi = true;
799
- /** Self-serve change flow only (no provider/admin pricing paths). */
800
- const isCustomerSelfServeChange = true;
801
- const isAdmin = false;
841
+ const suppressSelfServeCurrencyUi = isCustomerSelfServeChange;
802
842
 
803
843
  useEffect(() => {
804
844
  setPartnerAttributionConfirmed(false);
@@ -808,13 +848,13 @@ export function AdminChangeBookingFlow({
808
848
  * user picks a different return time — baseline is the first auto-selected return for this outbound.
809
849
  */
810
850
  const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
811
- /** Promo from booking is fixed — show read-only, never add new. */
851
+ /** Any change flow (self-serve or provider): promo from booking is fixed — show read-only, never add new. */
812
852
  const lockedPromoCode = initialValues?.promoCode?.trim()
813
853
  ? initialValues.promoCode.trim().toUpperCase()
814
854
  : null;
815
855
  /** Public self-serve only: cannot reduce tickets below original counts. */
816
856
  const changeBookingMinimumQuantities = useMemo(() => {
817
- if (!initialValues?.bookingItems?.length) return undefined;
857
+ if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
818
858
  const m: Record<string, number> = {};
819
859
  for (const item of initialValues.bookingItems) {
820
860
  const key = item.category?.trim();
@@ -822,7 +862,30 @@ export function AdminChangeBookingFlow({
822
862
  m[key] = Math.max(0, Number(item.count) || 0);
823
863
  }
824
864
  return m;
825
- }, [initialValues?.bookingItems]);
865
+ }, [isCustomerSelfServeChange, initialValues?.bookingItems]);
866
+ const [adminChoiceData, setAdminChoiceData] = useState<{
867
+ reservationReference: string;
868
+ reservationExpiration?: string;
869
+ checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
870
+ totalAmount: number;
871
+ datePart: string;
872
+ timePart: string;
873
+ availabilityProductOptionId: string;
874
+ itineraryDisplay?: ItineraryDisplayStep[] | null;
875
+ clientSecret: string;
876
+ ticketLinesForModal: CheckoutModalLineItem[];
877
+ feeLineItems: OrderSummary['feeLineItems'];
878
+ returnPriceAdjustment: number;
879
+ cancellationPolicyFee: number;
880
+ cancellationPolicyLabel?: string;
881
+ subtotal: number;
882
+ tax: number;
883
+ totalQuantity: number;
884
+ isTaxIncludedInPrice: boolean;
885
+ taxRate: number;
886
+ promoDiscountAmount: number;
887
+ discountLabel?: string | null;
888
+ } | null>(null);
826
889
  const [latestChangeQuote, setLatestChangeQuote] = useState<{
827
890
  priceDiff: number;
828
891
  currency: Currency;
@@ -1659,12 +1722,14 @@ export function AdminChangeBookingFlow({
1659
1722
  }, [initialValues?.productId, product.productId]);
1660
1723
 
1661
1724
  /**
1662
- * Receipt pricing on protected seats/fees: Rule A (exact receipt unit when same calendar day
1663
- * + same product option) vs Rule B (`max(receipt, live)` when date or option changes).
1725
+ * Receipt pricing on protected seats/fees — **customer self-serve only**: Rule A (exact receipt unit when same calendar day
1726
+ * + same product option) vs Rule B (`max(receipt, live)` when date or option changes). Provider dashboard uses live catalog.
1664
1727
  */
1665
1728
  const changeFlowApplyReceiptPaidFloors = useMemo(
1666
- () => changeFlowBookingParentProductIdForFloors === product.productId.trim(),
1667
- [changeFlowBookingParentProductIdForFloors, product.productId],
1729
+ () =>
1730
+ !isProviderDashboardChange &&
1731
+ changeFlowBookingParentProductIdForFloors === product.productId.trim(),
1732
+ [isProviderDashboardChange, changeFlowBookingParentProductIdForFloors, product.productId],
1668
1733
  );
1669
1734
 
1670
1735
  useEffect(() => {
@@ -2774,6 +2839,7 @@ export function AdminChangeBookingFlow({
2774
2839
  */
2775
2840
  const changeFlowProtectedReturnAdjustment = useMemo(() => {
2776
2841
  if (totalQuantity <= 0) return returnPriceAdjustment;
2842
+ if (isProviderDashboardChange) return returnPriceAdjustment;
2777
2843
  if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
2778
2844
  const livePerPerson =
2779
2845
  returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
@@ -2787,6 +2853,7 @@ export function AdminChangeBookingFlow({
2787
2853
  }, [
2788
2854
  totalQuantity,
2789
2855
  returnPriceAdjustment,
2856
+ isProviderDashboardChange,
2790
2857
  effectiveChangeFlowReturnUnitFloorPerPerson,
2791
2858
  selectedReturnOption,
2792
2859
  returnOptionCatalogPerPerson,
@@ -2877,6 +2944,29 @@ export function AdminChangeBookingFlow({
2877
2944
  return [...feeLineItems, ...addOnLines];
2878
2945
  }, [feeLineItems, addOnSelections, addOns]);
2879
2946
 
2947
+ const providerPricingUi = flowUi?.providerDashboardChangePricingUi;
2948
+ const providerQuotedLines = providerPricingUi?.quotedLines ?? [];
2949
+ const providerEditableLines = providerQuotedLines.filter((line) => isManualOverrideEligibleLine(line));
2950
+ const showProviderPricingInlineEditor =
2951
+ isProviderDashboardChange && isAdmin && (
2952
+ providerPricingUi?.loading ||
2953
+ providerPricingUi?.error != null ||
2954
+ providerQuotedLines.length > 0
2955
+ );
2956
+ const normalizeReceiptLabel = (value: string): string =>
2957
+ value
2958
+ .toLowerCase()
2959
+ .replace(/\([^)]*\)/g, '')
2960
+ .replace(/[^a-z0-9]+/g, '')
2961
+ .trim();
2962
+ const providerEditableLineByNormalizedLabel = useMemo(() => {
2963
+ const m = new Map<string, (typeof providerEditableLines)[number]>();
2964
+ for (const line of providerEditableLines) {
2965
+ const key = normalizeReceiptLabel(line.label ?? '');
2966
+ if (key) m.set(key, line);
2967
+ }
2968
+ return m;
2969
+ }, [providerEditableLines]);
2880
2970
 
2881
2971
  const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
2882
2972
  if (!selectedAvailability) return [];
@@ -2894,6 +2984,13 @@ export function AdminChangeBookingFlow({
2894
2984
  );
2895
2985
  return {
2896
2986
  kind: 'ticket',
2987
+ lineKey:
2988
+ showProviderPricingInlineEditor
2989
+ ? providerEditableLineByNormalizedLabel.get(normalizeReceiptLabel(line.category))?.lineKey
2990
+ : undefined,
2991
+ editable:
2992
+ showProviderPricingInlineEditor &&
2993
+ providerEditableLineByNormalizedLabel.has(normalizeReceiptLabel(line.category)),
2897
2994
  category: line.category,
2898
2995
  qty: line.qty,
2899
2996
  itemTotal: line.itemTotal,
@@ -2928,6 +3025,25 @@ export function AdminChangeBookingFlow({
2928
3025
  fee.name.toLowerCase().includes('license'));
2929
3026
  return {
2930
3027
  kind: 'line' as const,
3028
+ lineKey:
3029
+ showProviderPricingInlineEditor
3030
+ ? providerEditableLineByNormalizedLabel.get(
3031
+ normalizeReceiptLabel(
3032
+ feeLineItems.some((f) => f.name === fee.name)
3033
+ ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
3034
+ : fee.name
3035
+ )
3036
+ )?.lineKey
3037
+ : undefined,
3038
+ editable:
3039
+ showProviderPricingInlineEditor &&
3040
+ providerEditableLineByNormalizedLabel.has(
3041
+ normalizeReceiptLabel(
3042
+ feeLineItems.some((f) => f.name === fee.name)
3043
+ ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
3044
+ : fee.name
3045
+ )
3046
+ ),
2931
3047
  label: feeLineItems.some((f) => f.name === fee.name)
2932
3048
  ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
2933
3049
  : fee.name,
@@ -2953,6 +3069,8 @@ export function AdminChangeBookingFlow({
2953
3069
  effectiveCancellationPolicyLabel,
2954
3070
  feeLineItemsWithAddOns,
2955
3071
  feeLineItems,
3072
+ showProviderPricingInlineEditor,
3073
+ providerEditableLineByNormalizedLabel,
2956
3074
  ]);
2957
3075
 
2958
3076
  const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
@@ -3456,7 +3574,8 @@ export function AdminChangeBookingFlow({
3456
3574
  countsChanged ||
3457
3575
  addOnsChanged ||
3458
3576
  returnChanged,
3459
- // Authoritative for "real user change" gating: ignore option-id noise; only user-visible deltas.
3577
+ // Authoritative for "real user change" gating in provider dashboard:
3578
+ // ignore option-id noise and only consider user-visible booking deltas.
3460
3579
  hasOperationalChangesFromInitial:
3461
3580
  dateChanged ||
3462
3581
  pickupChanged ||
@@ -3483,28 +3602,62 @@ export function AdminChangeBookingFlow({
3483
3602
  addOnSelections,
3484
3603
  initialAddOnMinQtyByKey,
3485
3604
  ]);
3486
- const hasChangeSelection = changeSelectionDetails.hasChangesFromInitial;
3605
+ const hasChangeSelection =
3606
+ isProviderDashboardChange
3607
+ ? changeSelectionDetails.hasOperationalChangesFromInitial
3608
+ : changeSelectionDetails.hasChangesFromInitial;
3487
3609
 
3488
3610
  const changeFlowNeedsServerPrice =
3611
+ isCustomerSelfServeChange &&
3489
3612
  hasChangeSelection &&
3490
3613
  !!initialValues?.bookingReference?.trim() &&
3491
3614
  !!lastName.trim();
3492
3615
 
3493
- const isChangeQuoteBlocked = latestChangeQuote?.canProceed === false;
3494
- const requiresReturnInChangeFlow = !!initialValues?.returnAvailabilityId?.trim();
3616
+ const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
3617
+ const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
3495
3618
  const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
3496
3619
 
3497
3620
  const changeFlowSubmitDisabled =
3498
3621
  missingRequiredReturnSelection ||
3499
- (changeFlowNeedsServerPrice &&
3622
+ (isCustomerSelfServeChange &&
3623
+ changeFlowNeedsServerPrice &&
3500
3624
  (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)));
3501
3625
 
3502
- const hasEffectiveChangeSelection = hasChangeSelection;
3626
+ const providerTotalsPreview =
3627
+ showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
3628
+ ? providerPricingUi.totalsPreview
3629
+ : null;
3630
+ const providerHasEditedLineOverrides =
3631
+ isProviderDashboardChange &&
3632
+ providerQuotedLines.some((line) => {
3633
+ if (!isManualOverrideEligibleLine(line)) return false;
3634
+ const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
3635
+ const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
3636
+ if (!Number.isFinite(parsed)) return false;
3637
+ const rounded = Math.round(parsed * 100) / 100;
3638
+ return Math.abs(rounded - line.amount) > 0.0001;
3639
+ });
3640
+ const providerHasAdditionalAdjustments =
3641
+ isProviderDashboardChange &&
3642
+ (providerPricingUi?.additionalAdjustments ?? []).some((adj) => {
3643
+ const parsed = Number((adj.amountInput ?? '').trim());
3644
+ const hasAmount = Number.isFinite(parsed) && parsed > 0;
3645
+ const currentAmount = hasAmount ? (Math.round(parsed * 100) / 100).toFixed(2) : '';
3646
+ const originalAmount = adj.originalAmountInput ?? '';
3647
+ const currentLabel = (adj.label ?? '').trim();
3648
+ const originalLabel = (adj.originalLabel ?? '').trim();
3649
+ const currentMode = adj.mode;
3650
+ const originalMode = adj.originalMode;
3651
+ if (!originalMode) return hasAmount || currentLabel.length > 0;
3652
+ return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
3653
+ });
3654
+ const hasEffectiveChangeSelection =
3655
+ hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
3503
3656
 
3504
3657
  const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
3505
- providerPreview: null,
3658
+ providerPreview: providerTotalsPreview,
3506
3659
  serverQuotePreview:
3507
- latestChangeQuote?.serverDisplay
3660
+ isCustomerSelfServeChange && latestChangeQuote?.serverDisplay
3508
3661
  ? latestChangeQuote.serverDisplay
3509
3662
  : null,
3510
3663
  fromCart: {
@@ -3520,13 +3673,13 @@ export function AdminChangeBookingFlow({
3520
3673
  const changeFlowClientEstimateDue = (() => {
3521
3674
  if (!originalReceipt) return totalPrice;
3522
3675
  // Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
3523
- if (latestChangeQuote != null && !changeQuoteFetchError) {
3676
+ if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
3524
3677
  return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
3525
3678
  }
3526
3679
  return changeFlowBalanceVsOriginal({
3527
3680
  newTotal: displayChangeFlowProposedTotal,
3528
3681
  originalReceiptTotal: originalReceipt.total,
3529
- audience: 'customer',
3682
+ audience: isProviderDashboardChange ? 'provider' : 'customer',
3530
3683
  });
3531
3684
  })();
3532
3685
 
@@ -3535,6 +3688,14 @@ export function AdminChangeBookingFlow({
3535
3688
 
3536
3689
  const changeCheckoutButtonLabel = (() => {
3537
3690
  if (!hasEffectiveChangeSelection) return undefined;
3691
+ if (isProviderDashboardChange) {
3692
+ const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
3693
+ return est > 0
3694
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3695
+ : est < 0
3696
+ ? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
3697
+ : 'Change booking (no charge)';
3698
+ }
3538
3699
  if (changeFlowNeedsServerPrice) {
3539
3700
  if (changeQuoteLoading) {
3540
3701
  const tr = t('booking.updatingPrice');
@@ -3568,8 +3729,39 @@ export function AdminChangeBookingFlow({
3568
3729
  const checkoutFormError =
3569
3730
  (error || '') ||
3570
3731
  (missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
3571
- (isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
3572
- (changeQuoteFetchError ?? '');
3732
+ (isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
3733
+ (isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
3734
+
3735
+ const providerPricingOverrides =
3736
+ isProviderDashboardChange && providerQuotedLines.length > 0
3737
+ ? providerQuotedLines
3738
+ .filter((line) => isManualOverrideEligibleLine(line))
3739
+ .map((line) => {
3740
+ const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
3741
+ const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
3742
+ if (!Number.isFinite(parsed)) return null;
3743
+ const rounded = Math.round(parsed * 100) / 100;
3744
+ return Math.abs(rounded - line.amount) > 0.0001
3745
+ ? { lineKey: line.lineKey, amount: rounded, reason: 'Provider dashboard override' }
3746
+ : null;
3747
+ })
3748
+ .filter((v): v is { lineKey: string; amount: number; reason: string } => v != null)
3749
+ : [];
3750
+ const providerAdditionalAdjustments =
3751
+ isProviderDashboardChange
3752
+ ? (providerPricingUi?.additionalAdjustments ?? [])
3753
+ .map((adj) => {
3754
+ const parsed = Number((adj.amountInput ?? '').trim());
3755
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
3756
+ const rounded = Math.round(parsed * 100) / 100;
3757
+ const signed = adj.mode === 'DISCOUNT' ? -rounded : rounded;
3758
+ return {
3759
+ label: adj.label?.trim() || (signed < 0 ? 'Provider discount' : 'Provider charge'),
3760
+ amount: signed,
3761
+ };
3762
+ })
3763
+ .filter((v): v is { label: string; amount: number } => v != null)
3764
+ : [];
3573
3765
 
3574
3766
  const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
3575
3767
  if (!selectedAvailability || totalQuantity <= 0) return null;
@@ -3817,7 +4009,8 @@ export function AdminChangeBookingFlow({
3817
4009
  timesForSelectedDate,
3818
4010
  anchor,
3819
4011
  companyTimezone,
3820
- optionsMap
4012
+ optionsMap,
4013
+ isAdmin
3821
4014
  );
3822
4015
  changeFlowOutboundAnchorRef.current = null;
3823
4016
  if (matched) {
@@ -3908,7 +4101,8 @@ export function AdminChangeBookingFlow({
3908
4101
  const fromAnchor = pickReturnMatchingPreviousSelection(
3909
4102
  sorted,
3910
4103
  returnAnchor,
3911
- companyTimezone
4104
+ companyTimezone,
4105
+ isAdmin
3912
4106
  );
3913
4107
  changeFlowReturnAnchorRef.current = null;
3914
4108
  if (fromAnchor) {
@@ -4231,6 +4425,8 @@ export function AdminChangeBookingFlow({
4231
4425
  return;
4232
4426
  }
4233
4427
 
4428
+ const skipContactFields = isProviderDashboardChange;
4429
+ if (!skipContactFields) {
4234
4430
  // Validate email (required)
4235
4431
  if (!email) {
4236
4432
  setError(t('booking.enterEmail') || 'Please enter your email address');
@@ -4253,6 +4449,7 @@ export function AdminChangeBookingFlow({
4253
4449
  setError(t('booking.enterLastName') || 'Please enter your last name');
4254
4450
  return;
4255
4451
  }
4452
+ }
4256
4453
 
4257
4454
  // Allow checkout if pickup location is selected OR if user chose "I don't know"
4258
4455
  if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
@@ -4279,6 +4476,45 @@ export function AdminChangeBookingFlow({
4279
4476
  return;
4280
4477
  }
4281
4478
 
4479
+ if (onChangeBooking && !deferProviderApplyToAdminPaymentChoice) {
4480
+ const pickupForChange = pickupLocationId
4481
+ ? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
4482
+ : null;
4483
+ await onChangeBooking({
4484
+ productId: availabilityProductOptionId,
4485
+ dateTime: selectedAvailability.dateTime,
4486
+ bookingItems,
4487
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
4488
+ pickupLocationId: pickupLocationId ?? null,
4489
+ travelerHotel: pickupForChange?.name ?? null,
4490
+ startTime: selectedAvailability.dateTime ?? null,
4491
+ passengerCount: null,
4492
+ childSafetySeatsCount: null,
4493
+ foodRestrictions: null,
4494
+ addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
4495
+ cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
4496
+ promoCode: appliedPromoCode ?? null,
4497
+ newTotalAmount: displayChangeFlowProposedTotal,
4498
+ additionalHoursCount: null,
4499
+ pricingAdjustment:
4500
+ providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
4501
+ ? {
4502
+ mode: 'MANUAL_LINES',
4503
+ lineOverrides: providerPricingOverrides,
4504
+ additionalAdjustments: providerAdditionalAdjustments,
4505
+ }
4506
+ : undefined,
4507
+ capacitySeatCredit: {
4508
+ enabled: true,
4509
+ previousPassengerCount: changeFlowInitialTicketCount,
4510
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
4511
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
4512
+ },
4513
+ });
4514
+ setLoading(false);
4515
+ return;
4516
+ }
4517
+
4282
4518
  const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
4283
4519
  clientChannelSource: inferClientBookingSourceFromProductIds(
4284
4520
  product.productId,
@@ -4296,6 +4532,7 @@ export function AdminChangeBookingFlow({
4296
4532
  let changeIntentIdForCheckout: string | undefined;
4297
4533
  let changeBookingReferenceForPaidFlow: string | undefined;
4298
4534
 
4535
+ if (isCustomerSelfServeChange) {
4299
4536
  const changeBookingReference = initialValues?.bookingReference?.trim();
4300
4537
  const changeLastName = lastName.trim();
4301
4538
  if (!changeBookingReference || !changeLastName) {
@@ -4383,12 +4620,13 @@ export function AdminChangeBookingFlow({
4383
4620
  if (!changeIntentIdForCheckout) {
4384
4621
  throw new Error('Missing change intent for payment.');
4385
4622
  }
4623
+ }
4386
4624
 
4387
4625
  pendingReservationRef.current = null;
4388
4626
 
4389
4627
  // Note: Do NOT call onSuccess here for paid bookings — we're about to show the Stripe
4390
4628
  // CheckoutModal. onSuccess (e.g. closing the parent dialog) should only run when we're
4391
- // actually done (free booking redirect). Calling it here
4629
+ // actually done (free booking redirect, admin confirm-without-payment). Calling it here
4392
4630
  // would close the dialog before the payment modal opens.
4393
4631
 
4394
4632
  // Update stored booking data (no holds reservation — change flow keys off booking reference elsewhere).
@@ -4414,11 +4652,13 @@ export function AdminChangeBookingFlow({
4414
4652
  // Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
4415
4653
  // Backend will charge totalAmount and store this as the receipt so /manage matches.
4416
4654
  const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
4417
- const amountDueForCheckout = changeFlowBalanceVsOriginal({
4655
+ const amountDueForCheckout = isCustomerSelfServeChange
4656
+ ? changeFlowBalanceVsOriginal({
4418
4657
  newTotal: changeFlowNewBookingTotal,
4419
4658
  originalReceiptTotal: originalReceipt?.total ?? 0,
4420
4659
  audience: 'customer',
4421
- });
4660
+ })
4661
+ : totalPrice;
4422
4662
  const lines = [
4423
4663
  ...ticketLineItemsForChangeFlowDisplay.map((line) => ({
4424
4664
  label: line.category,
@@ -4495,7 +4735,8 @@ export function AdminChangeBookingFlow({
4495
4735
  roundingLabel: t('booking.rounding') || 'Rounding',
4496
4736
  });
4497
4737
 
4498
- const paymentIntent = await createChangeBookingPaymentIntent(
4738
+ const paymentIntent = isCustomerSelfServeChange
4739
+ ? await createChangeBookingPaymentIntent(
4499
4740
  (() => {
4500
4741
  const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
4501
4742
  if (!id) {
@@ -4503,7 +4744,76 @@ export function AdminChangeBookingFlow({
4503
4744
  }
4504
4745
  return id;
4505
4746
  })()
4506
- );
4747
+ )
4748
+ : await createPaymentIntent({
4749
+ productId: product.productId,
4750
+ optionId: availabilityProductOptionId,
4751
+ date: datePart,
4752
+ time: timePart,
4753
+ quantity: totalQuantity,
4754
+ customerEmail: email,
4755
+ customerFirstName: firstName.trim() || undefined,
4756
+ customerLastName: lastName.trim() || undefined,
4757
+ currency: currency,
4758
+ travelerHotel: selectedPickupLocation?.name || undefined,
4759
+ pickupLocationId: pickupLocationId || undefined,
4760
+ itineraryDisplay: itineraryDisplay ?? undefined,
4761
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
4762
+ promoCode: (lockedPromoCode || appliedPromoCode) || undefined,
4763
+ cancellationPolicyId: cancellationPolicyId || undefined,
4764
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
4765
+ checkoutBreakdown,
4766
+ skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
4767
+ disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
4768
+ ...bookingSourceContext,
4769
+ });
4770
+
4771
+ // Admin: show choice to pay now or confirm without payment (customer owes full balance)
4772
+ if (isAdmin) {
4773
+ const adminReservationRef =
4774
+ initialValues?.bookingReference?.trim() ??
4775
+ changeBookingReferenceForPaidFlow ??
4776
+ '';
4777
+ if (!adminReservationRef) {
4778
+ throw new Error('Missing reservation reference for admin payment flow');
4779
+ }
4780
+ setError('');
4781
+ setAdminChoiceData({
4782
+ reservationReference: adminReservationRef,
4783
+ reservationExpiration: undefined,
4784
+ checkoutBreakdown,
4785
+ totalAmount: amountDueForCheckout,
4786
+ datePart,
4787
+ timePart,
4788
+ availabilityProductOptionId,
4789
+ itineraryDisplay: itineraryDisplay ?? undefined,
4790
+ clientSecret: paymentIntent.clientSecret ?? '',
4791
+ ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
4792
+ const rate = pricing.find((r) => r.category === line.category);
4793
+ const breakdown = getPriceBreakdown(
4794
+ line.category,
4795
+ rate?.priceCAD ?? 0,
4796
+ rate?.baseInDisplayCurrency,
4797
+ rate?.appliedAdjustments ?? []
4798
+ );
4799
+ return { line, breakdown };
4800
+ }),
4801
+ feeLineItems: feeLineItemsWithAddOns,
4802
+ returnPriceAdjustment: checkoutReturnLineAmount,
4803
+ cancellationPolicyFee,
4804
+ cancellationPolicyLabel: effectiveCancellationPolicyLabel,
4805
+ subtotal: effectiveSubtotal,
4806
+ tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
4807
+ totalQuantity,
4808
+ isTaxIncludedInPrice,
4809
+ taxRate: pricingConfig?.taxRate ?? 0,
4810
+ promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
4811
+ discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
4812
+ });
4813
+ setShowAdminPaymentChoice(true);
4814
+ setLoading(false);
4815
+ return;
4816
+ }
4507
4817
 
4508
4818
  const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
4509
4819
  const rate = pricing.find((r) => r.category === line.category);
@@ -4525,7 +4835,7 @@ export function AdminChangeBookingFlow({
4525
4835
  // Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
4526
4836
  // /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
4527
4837
  successUrlOverride:
4528
- changeBookingReferenceForPaidFlow
4838
+ isCustomerSelfServeChange && changeBookingReferenceForPaidFlow
4529
4839
  ? (() => {
4530
4840
  const origin = typeof window !== 'undefined' ? window.location.origin : '';
4531
4841
  const ref = encodeURIComponent(
@@ -4553,7 +4863,7 @@ export function AdminChangeBookingFlow({
4553
4863
  promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
4554
4864
  discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
4555
4865
  changeTotals:
4556
- originalReceipt
4866
+ isCustomerSelfServeChange && originalReceipt
4557
4867
  ? {
4558
4868
  previousTotal: originalReceipt.total,
4559
4869
  newTotal: displayChangeFlowProposedTotal,
@@ -4622,6 +4932,162 @@ export function AdminChangeBookingFlow({
4622
4932
  }
4623
4933
  };
4624
4934
 
4935
+ const handleConfirmWithoutPayment = async () => {
4936
+ if (!adminChoiceData) return;
4937
+ setLoading(true);
4938
+ setError('');
4939
+ try {
4940
+ if (onChangeBooking) {
4941
+ if (!selectedAvailability) {
4942
+ setError('No availability selected');
4943
+ setLoading(false);
4944
+ return;
4945
+ }
4946
+ const bookingItems = Object.entries(quantities)
4947
+ .filter(([, count]) => count > 0)
4948
+ .map(([category, count]) => ({ category, count }));
4949
+ const availabilityProductOptionId =
4950
+ adminChoiceData.availabilityProductOptionId ||
4951
+ selectedAvailability.productOptionId ||
4952
+ activeOptions[0]?.optionId;
4953
+ if (!availabilityProductOptionId) {
4954
+ setError('No product option selected');
4955
+ setLoading(false);
4956
+ return;
4957
+ }
4958
+ const pickupForChange = pickupLocationId
4959
+ ? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
4960
+ : null;
4961
+ await onChangeBooking({
4962
+ productId: availabilityProductOptionId,
4963
+ dateTime: selectedAvailability.dateTime,
4964
+ bookingItems,
4965
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
4966
+ pickupLocationId: pickupLocationId ?? null,
4967
+ travelerHotel: pickupForChange?.name ?? null,
4968
+ startTime: selectedAvailability.dateTime ?? null,
4969
+ passengerCount: null,
4970
+ childSafetySeatsCount: null,
4971
+ foodRestrictions: null,
4972
+ addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
4973
+ cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
4974
+ promoCode: appliedPromoCode ?? null,
4975
+ newTotalAmount: displayChangeFlowProposedTotal,
4976
+ additionalHoursCount: null,
4977
+ pricingAdjustment:
4978
+ providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
4979
+ ? {
4980
+ mode: 'MANUAL_LINES',
4981
+ lineOverrides: providerPricingOverrides,
4982
+ additionalAdjustments: providerAdditionalAdjustments,
4983
+ }
4984
+ : undefined,
4985
+ capacitySeatCredit: {
4986
+ enabled: true,
4987
+ previousPassengerCount: changeFlowInitialTicketCount,
4988
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
4989
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
4990
+ },
4991
+ });
4992
+ const bookRef = initialValues?.bookingReference?.trim() || adminChoiceData.reservationReference;
4993
+ setShowAdminPaymentChoice(false);
4994
+ setAdminChoiceData(null);
4995
+ onSuccess?.({ reservationReference: bookRef });
4996
+ const ref = formatBookingRefForDisplay(bookRef);
4997
+ const ln = lastName.trim();
4998
+ if (onShowManage) {
4999
+ onShowManage({ ref, lastName: ln });
5000
+ } else {
5001
+ const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
5002
+ window.location.href = `/manage-booking?${params.toString()}`;
5003
+ }
5004
+ setLoading(false);
5005
+ return;
5006
+ }
5007
+
5008
+ if (isChangeBookingContext) {
5009
+ setError(
5010
+ 'Confirming a booking change without payment requires your dashboard to pass the provider change handler (onChangeBooking). Use Pay now to complete this change, or wire onChangeBooking in the embed.',
5011
+ );
5012
+ setLoading(false);
5013
+ return;
5014
+ }
5015
+
5016
+ const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
5017
+ clientChannelSource: inferClientBookingSourceFromProductIds(
5018
+ product.productId,
5019
+ adminChoiceData.availabilityProductOptionId,
5020
+ ),
5021
+ forcePartnerPortalChannel: partnerPortalBooking,
5022
+ forceDashboardSource: bookingAppMode === 'provider-dashboard',
5023
+ });
5024
+ const result = await confirmBookingWithoutPayment({
5025
+ reservationReference: adminChoiceData.reservationReference,
5026
+ productId: product.productId,
5027
+ optionId: adminChoiceData.availabilityProductOptionId,
5028
+ date: adminChoiceData.datePart,
5029
+ time: adminChoiceData.timePart,
5030
+ customerEmail: email || undefined,
5031
+ customerFirstName: firstName.trim() || undefined,
5032
+ customerLastName: lastName.trim() || undefined,
5033
+ currency: currency,
5034
+ travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
5035
+ pickupLocationId: pickupLocationId || undefined,
5036
+ itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
5037
+ termsAcceptedAt: termsAcceptedAt ?? undefined,
5038
+ skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
5039
+ disableAutoCommunications: disableAutoCommunications ? true : undefined,
5040
+ checkoutBreakdown: adminChoiceData.checkoutBreakdown,
5041
+ depositAmount: 0,
5042
+ balanceAmount: adminChoiceData.totalAmount,
5043
+ totalAmount: adminChoiceData.totalAmount,
5044
+ ...bookingSourceContext,
5045
+ });
5046
+ pendingReservationRef.current = null;
5047
+ const ref = formatBookingRefForDisplay(result.bookingReference);
5048
+ const ln = lastName.trim();
5049
+ setShowAdminPaymentChoice(false);
5050
+ setAdminChoiceData(null);
5051
+ onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
5052
+ if (onShowManage) {
5053
+ onShowManage({ ref, lastName: ln });
5054
+ } else {
5055
+ const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
5056
+ window.location.href = `/manage-booking?${params.toString()}`;
5057
+ }
5058
+ } catch (err) {
5059
+ setError(err instanceof Error ? err.message : 'Failed to confirm booking');
5060
+ } finally {
5061
+ setLoading(false);
5062
+ }
5063
+ };
5064
+
5065
+ const handlePayNow = () => {
5066
+ if (!adminChoiceData) return;
5067
+ setShowAdminPaymentChoice(false);
5068
+ setCheckoutClientSecret(adminChoiceData.clientSecret);
5069
+ setCheckoutModalData({
5070
+ reservationReference: adminChoiceData.reservationReference,
5071
+ reservationExpiration: adminChoiceData.reservationExpiration,
5072
+ customerLastName: lastName.trim(),
5073
+ ticketLines: adminChoiceData.ticketLinesForModal,
5074
+ feeLineItems: adminChoiceData.feeLineItems,
5075
+ returnPriceAdjustment: adminChoiceData.returnPriceAdjustment,
5076
+ cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
5077
+ cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
5078
+ subtotal: adminChoiceData.subtotal,
5079
+ tax: adminChoiceData.tax,
5080
+ total: adminChoiceData.totalAmount,
5081
+ totalQuantity: adminChoiceData.totalQuantity,
5082
+ isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
5083
+ taxRate: adminChoiceData.taxRate,
5084
+ promoDiscountAmount: adminChoiceData.promoDiscountAmount,
5085
+ discountLabel: adminChoiceData.discountLabel,
5086
+ });
5087
+ setShowCheckoutModal(true);
5088
+ setAdminChoiceData(null);
5089
+ };
5090
+
4625
5091
  if (activeOptions.length === 0) {
4626
5092
  return (
4627
5093
  <div className="flex items-center justify-center py-16">
@@ -4632,6 +5098,17 @@ export function AdminChangeBookingFlow({
4632
5098
 
4633
5099
  return (
4634
5100
  <div className="booking-flow-root space-y-8">
5101
+ {/* Admin: choose to pay now or confirm without payment (full balance owed) */}
5102
+ <AdminPaymentChoiceModal
5103
+ open={!!(showAdminPaymentChoice && adminChoiceData)}
5104
+ totalAmount={adminChoiceData?.totalAmount ?? 0}
5105
+ currency={currency}
5106
+ loading={loading}
5107
+ error={error}
5108
+ onPayNow={handlePayNow}
5109
+ onConfirmWithoutPayment={handleConfirmWithoutPayment}
5110
+ onCancel={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
5111
+ />
4635
5112
  {checkoutModalData && (
4636
5113
  <CheckoutModal
4637
5114
  open={showCheckoutModal}
@@ -4886,8 +5363,92 @@ export function AdminChangeBookingFlow({
4886
5363
  currency={currency}
4887
5364
  locale={locale}
4888
5365
  t={t}
4889
- extraBetweenTaxAndTotal={<></>}
4890
- extraBeforeSubtotal={undefined}
5366
+ extraBetweenTaxAndTotal={
5367
+ <>
5368
+ {showProviderPricingInlineEditor && providerPricingUi?.error ? (
5369
+ <div className="mt-2 text-sm text-red-700">{providerPricingUi.error}</div>
5370
+ ) : null}
5371
+ {showProviderPricingInlineEditor &&
5372
+ providerPricingUi?.loading &&
5373
+ providerQuotedLines.length === 0 ? (
5374
+ <div className="mt-2 text-sm text-stone-500">Loading price lines...</div>
5375
+ ) : null}
5376
+ {showProviderPricingInlineEditor &&
5377
+ providerPricingUi?.helperText &&
5378
+ !providerPricingUi.error ? (
5379
+ <div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
5380
+ ) : null}
5381
+ </>
5382
+ }
5383
+ extraBeforeSubtotal={
5384
+ showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
5385
+ <div className="space-y-1">
5386
+ {providerPricingUi?.additionalAdjustments?.map((adj) => (
5387
+ <div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
5388
+ <div className="flex min-w-0 items-center gap-1">
5389
+ <button
5390
+ type="button"
5391
+ className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
5392
+ onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
5393
+ >
5394
+ -
5395
+ </button>
5396
+ <input
5397
+ type="text"
5398
+ className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
5399
+ placeholder="Line description"
5400
+ value={adj.label}
5401
+ onChange={(e) =>
5402
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
5403
+ }
5404
+ />
5405
+ </div>
5406
+ <div className="flex items-center gap-1">
5407
+ <select
5408
+ className="rounded border border-stone-300 px-1 py-0.5 text-xs"
5409
+ value={adj.mode}
5410
+ onChange={(e) =>
5411
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5412
+ mode: e.target.value as 'DISCOUNT' | 'CHARGE',
5413
+ })
5414
+ }
5415
+ >
5416
+ <option value="DISCOUNT">-</option>
5417
+ <option value="CHARGE">+</option>
5418
+ </select>
5419
+ <input
5420
+ type="text"
5421
+ inputMode="decimal"
5422
+ 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"
5423
+ placeholder="0.00"
5424
+ value={adj.amountInput}
5425
+ onChange={(e) =>
5426
+ providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
5427
+ amountInput: e.target.value,
5428
+ })
5429
+ }
5430
+ />
5431
+ </div>
5432
+ </div>
5433
+ ))}
5434
+ <button
5435
+ type="button"
5436
+ className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5437
+ onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5438
+ >
5439
+ + add line item
5440
+ </button>
5441
+ </div>
5442
+ ) : showProviderPricingInlineEditor ? (
5443
+ <button
5444
+ type="button"
5445
+ className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
5446
+ onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
5447
+ >
5448
+ + add line item
5449
+ </button>
5450
+ ) : undefined
5451
+ }
4891
5452
  firstName={firstName}
4892
5453
  lastName={lastName}
4893
5454
  email={email}
@@ -4928,12 +5489,12 @@ export function AdminChangeBookingFlow({
4928
5489
  setTermsAccepted(checked);
4929
5490
  setTermsAcceptedAt(checked ? new Date().toISOString() : null);
4930
5491
  }}
4931
- isAdmin={false}
5492
+ isAdmin={isAdmin}
4932
5493
  showCommunicationAdminSection={false}
4933
- skipConfirmationCommunications={false}
4934
- disableAutoCommunications={false}
4935
- onSkipConfirmationChange={() => {}}
4936
- onDisableCommunicationsChange={() => {}}
5494
+ skipConfirmationCommunications={skipConfirmationCommunications}
5495
+ disableAutoCommunications={disableAutoCommunications}
5496
+ onSkipConfirmationChange={setSkipConfirmationCommunications}
5497
+ onDisableCommunicationsChange={setDisableAutoCommunications}
4937
5498
  error={checkoutFormError}
4938
5499
  loading={loading}
4939
5500
  totalQuantity={totalQuantity}
@@ -4941,6 +5502,7 @@ export function AdminChangeBookingFlow({
4941
5502
  submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
4942
5503
  hideSubmitButton={
4943
5504
  showCheckoutModal ||
5505
+ showAdminPaymentChoice ||
4944
5506
  !hasEffectiveChangeSelection ||
4945
5507
  isChangeQuoteBlocked
4946
5508
  }
@@ -4949,10 +5511,16 @@ export function AdminChangeBookingFlow({
4949
5511
  attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
4950
5512
  attributionConfirmed={partnerAttributionConfirmed}
4951
5513
  onAttributionConfirmedChange={setPartnerAttributionConfirmed}
4952
- lineAmountInputs={undefined}
4953
- onLineAmountInputChange={undefined}
4954
- onLineAmountInputBlur={undefined}
4955
- onLineAmountReset={undefined}
5514
+ lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
5515
+ onLineAmountInputChange={
5516
+ showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
5517
+ }
5518
+ onLineAmountInputBlur={
5519
+ showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
5520
+ }
5521
+ onLineAmountReset={
5522
+ showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
5523
+ }
4956
5524
  />
4957
5525
  </>
4958
5526
  )}
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
4
- import { createPortal } from 'react-dom';
5
4
  import { loadStripe } from '@stripe/stripe-js';
6
5
  import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
7
6
  import { useBookingHost } from '../../runtime';
@@ -238,10 +237,12 @@ export function CheckoutModal({
238
237
  if (!env.STRIPE_PUBLISHABLE_KEY) {
239
238
  const noStripe = (
240
239
  <div
241
- className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
240
+ className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
242
241
  style={{ zIndex: 100_000 }}
242
+ role="dialog"
243
+ aria-modal="true"
243
244
  >
244
- <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
245
+ <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 pointer-events-auto" onClick={(e) => e.stopPropagation()}>
245
246
  <h3 className="text-lg font-semibold text-stone-900 mb-2">
246
247
  {t('booking.checkout') || 'Checkout'}
247
248
  </h3>
@@ -258,7 +259,7 @@ export function CheckoutModal({
258
259
  </div>
259
260
  </div>
260
261
  );
261
- return typeof document !== 'undefined' ? createPortal(noStripe, document.body) : null;
262
+ return noStripe;
262
263
  }
263
264
 
264
265
  const options = {
@@ -274,10 +275,15 @@ export function CheckoutModal({
274
275
 
275
276
  const checkout = (
276
277
  <div
277
- className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
278
+ className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50 pointer-events-auto"
278
279
  style={{ zIndex: 100_000 }}
280
+ role="dialog"
281
+ aria-modal="true"
279
282
  >
280
- <div className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col">
283
+ <div
284
+ className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col pointer-events-auto"
285
+ onClick={(e) => e.stopPropagation()}
286
+ >
281
287
  <div className="p-6 border-b border-stone-200 flex-shrink-0">
282
288
  <div className="flex justify-between items-start">
283
289
  <h3 className="text-lg font-semibold text-stone-900">
@@ -449,5 +455,5 @@ export function CheckoutModal({
449
455
  </div>
450
456
  </div>
451
457
  );
452
- return typeof document !== 'undefined' ? createPortal(checkout, document.body) : null;
458
+ return checkout;
453
459
  }
@@ -3,6 +3,7 @@ import type { Product } from '../../lib/booking-api';
3
3
  import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
4
4
  import type { Currency } from './CurrencySwitcher';
5
5
  import type { BookingFlowUiOptions } from './booking-flow-ui';
6
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
6
7
 
7
8
  /** Live selection snapshot for change-booking compare UI (parent dialog). */
8
9
  export interface ChangeFlowSelectionPreview {
@@ -126,6 +127,8 @@ export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
126
127
  quantity?: number;
127
128
  }>;
128
129
  } | null;
130
+ /** Admin/provider dashboard integration hook (used by AdminChangeBookingFlow). */
131
+ onChangeBooking?: (data: ProviderDashboardChangeBookingPayload) => Promise<void>;
129
132
  /**
130
133
  * Embed compatibility (e.g. ticketbooth provider dashboard): ignored — same flow as the public site.
131
134
  * When true, {@link BookingFlow} still renders the fork {@link AdminChangeBookingFlow} for future divergence.