@ticketboothapp/booking 1.2.65 → 1.2.67
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
|
@@ -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
|
-
*
|
|
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,10 @@ export function AdminChangeBookingFlow({
|
|
|
794
822
|
return null;
|
|
795
823
|
}
|
|
796
824
|
}, [initialValues?.dateTime, companyTimezone]);
|
|
825
|
+
const isProviderDashboardChange = Boolean(onChangeBooking);
|
|
826
|
+
const isCustomerSelfServeChange = !isProviderDashboardChange;
|
|
797
827
|
/** Do not render catalog-/FE-derived dollar amounts in UI until `quoteChangeBooking` returns `serverDisplay`. */
|
|
798
|
-
const suppressSelfServeCurrencyUi =
|
|
799
|
-
/** Self-serve change flow only (no provider/admin pricing paths). */
|
|
800
|
-
const isCustomerSelfServeChange = true;
|
|
801
|
-
const isAdmin = false;
|
|
828
|
+
const suppressSelfServeCurrencyUi = isCustomerSelfServeChange;
|
|
802
829
|
|
|
803
830
|
useEffect(() => {
|
|
804
831
|
setPartnerAttributionConfirmed(false);
|
|
@@ -808,13 +835,13 @@ export function AdminChangeBookingFlow({
|
|
|
808
835
|
* user picks a different return time — baseline is the first auto-selected return for this outbound.
|
|
809
836
|
*/
|
|
810
837
|
const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
|
|
811
|
-
/**
|
|
838
|
+
/** Any change flow (self-serve or provider): promo from booking is fixed — show read-only, never add new. */
|
|
812
839
|
const lockedPromoCode = initialValues?.promoCode?.trim()
|
|
813
840
|
? initialValues.promoCode.trim().toUpperCase()
|
|
814
841
|
: null;
|
|
815
842
|
/** Public self-serve only: cannot reduce tickets below original counts. */
|
|
816
843
|
const changeBookingMinimumQuantities = useMemo(() => {
|
|
817
|
-
if (!initialValues?.bookingItems?.length) return undefined;
|
|
844
|
+
if (!isCustomerSelfServeChange || !initialValues?.bookingItems?.length) return undefined;
|
|
818
845
|
const m: Record<string, number> = {};
|
|
819
846
|
for (const item of initialValues.bookingItems) {
|
|
820
847
|
const key = item.category?.trim();
|
|
@@ -822,7 +849,30 @@ export function AdminChangeBookingFlow({
|
|
|
822
849
|
m[key] = Math.max(0, Number(item.count) || 0);
|
|
823
850
|
}
|
|
824
851
|
return m;
|
|
825
|
-
}, [initialValues?.bookingItems]);
|
|
852
|
+
}, [isCustomerSelfServeChange, initialValues?.bookingItems]);
|
|
853
|
+
const [adminChoiceData, setAdminChoiceData] = useState<{
|
|
854
|
+
reservationReference: string;
|
|
855
|
+
reservationExpiration?: string;
|
|
856
|
+
checkoutBreakdown: { lineItems: Array<{ label: string; amount: number; type?: string; quantity?: number }>; totalAmount: number; currency: string };
|
|
857
|
+
totalAmount: number;
|
|
858
|
+
datePart: string;
|
|
859
|
+
timePart: string;
|
|
860
|
+
availabilityProductOptionId: string;
|
|
861
|
+
itineraryDisplay?: ItineraryDisplayStep[] | null;
|
|
862
|
+
clientSecret: string;
|
|
863
|
+
ticketLinesForModal: CheckoutModalLineItem[];
|
|
864
|
+
feeLineItems: OrderSummary['feeLineItems'];
|
|
865
|
+
returnPriceAdjustment: number;
|
|
866
|
+
cancellationPolicyFee: number;
|
|
867
|
+
cancellationPolicyLabel?: string;
|
|
868
|
+
subtotal: number;
|
|
869
|
+
tax: number;
|
|
870
|
+
totalQuantity: number;
|
|
871
|
+
isTaxIncludedInPrice: boolean;
|
|
872
|
+
taxRate: number;
|
|
873
|
+
promoDiscountAmount: number;
|
|
874
|
+
discountLabel?: string | null;
|
|
875
|
+
} | null>(null);
|
|
826
876
|
const [latestChangeQuote, setLatestChangeQuote] = useState<{
|
|
827
877
|
priceDiff: number;
|
|
828
878
|
currency: Currency;
|
|
@@ -1659,12 +1709,14 @@ export function AdminChangeBookingFlow({
|
|
|
1659
1709
|
}, [initialValues?.productId, product.productId]);
|
|
1660
1710
|
|
|
1661
1711
|
/**
|
|
1662
|
-
* Receipt pricing on protected seats/fees
|
|
1663
|
-
* + same product option) vs Rule B (`max(receipt, live)` when date or option changes).
|
|
1712
|
+
* Receipt pricing on protected seats/fees — **customer self-serve only**: Rule A (exact receipt unit when same calendar day
|
|
1713
|
+
* + same product option) vs Rule B (`max(receipt, live)` when date or option changes). Provider dashboard uses live catalog.
|
|
1664
1714
|
*/
|
|
1665
1715
|
const changeFlowApplyReceiptPaidFloors = useMemo(
|
|
1666
|
-
() =>
|
|
1667
|
-
|
|
1716
|
+
() =>
|
|
1717
|
+
!isProviderDashboardChange &&
|
|
1718
|
+
changeFlowBookingParentProductIdForFloors === product.productId.trim(),
|
|
1719
|
+
[isProviderDashboardChange, changeFlowBookingParentProductIdForFloors, product.productId],
|
|
1668
1720
|
);
|
|
1669
1721
|
|
|
1670
1722
|
useEffect(() => {
|
|
@@ -2774,6 +2826,7 @@ export function AdminChangeBookingFlow({
|
|
|
2774
2826
|
*/
|
|
2775
2827
|
const changeFlowProtectedReturnAdjustment = useMemo(() => {
|
|
2776
2828
|
if (totalQuantity <= 0) return returnPriceAdjustment;
|
|
2829
|
+
if (isProviderDashboardChange) return returnPriceAdjustment;
|
|
2777
2830
|
if (effectiveChangeFlowReturnUnitFloorPerPerson == null) return returnPriceAdjustment;
|
|
2778
2831
|
const livePerPerson =
|
|
2779
2832
|
returnOptionCatalogPerPerson ?? (selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0);
|
|
@@ -2787,6 +2840,7 @@ export function AdminChangeBookingFlow({
|
|
|
2787
2840
|
}, [
|
|
2788
2841
|
totalQuantity,
|
|
2789
2842
|
returnPriceAdjustment,
|
|
2843
|
+
isProviderDashboardChange,
|
|
2790
2844
|
effectiveChangeFlowReturnUnitFloorPerPerson,
|
|
2791
2845
|
selectedReturnOption,
|
|
2792
2846
|
returnOptionCatalogPerPerson,
|
|
@@ -2877,6 +2931,29 @@ export function AdminChangeBookingFlow({
|
|
|
2877
2931
|
return [...feeLineItems, ...addOnLines];
|
|
2878
2932
|
}, [feeLineItems, addOnSelections, addOns]);
|
|
2879
2933
|
|
|
2934
|
+
const providerPricingUi = flowUi?.providerDashboardChangePricingUi;
|
|
2935
|
+
const providerQuotedLines = providerPricingUi?.quotedLines ?? [];
|
|
2936
|
+
const providerEditableLines = providerQuotedLines.filter((line) => isManualOverrideEligibleLine(line));
|
|
2937
|
+
const showProviderPricingInlineEditor =
|
|
2938
|
+
isProviderDashboardChange && isAdmin && (
|
|
2939
|
+
providerPricingUi?.loading ||
|
|
2940
|
+
providerPricingUi?.error != null ||
|
|
2941
|
+
providerQuotedLines.length > 0
|
|
2942
|
+
);
|
|
2943
|
+
const normalizeReceiptLabel = (value: string): string =>
|
|
2944
|
+
value
|
|
2945
|
+
.toLowerCase()
|
|
2946
|
+
.replace(/\([^)]*\)/g, '')
|
|
2947
|
+
.replace(/[^a-z0-9]+/g, '')
|
|
2948
|
+
.trim();
|
|
2949
|
+
const providerEditableLineByNormalizedLabel = useMemo(() => {
|
|
2950
|
+
const m = new Map<string, (typeof providerEditableLines)[number]>();
|
|
2951
|
+
for (const line of providerEditableLines) {
|
|
2952
|
+
const key = normalizeReceiptLabel(line.label ?? '');
|
|
2953
|
+
if (key) m.set(key, line);
|
|
2954
|
+
}
|
|
2955
|
+
return m;
|
|
2956
|
+
}, [providerEditableLines]);
|
|
2880
2957
|
|
|
2881
2958
|
const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
|
|
2882
2959
|
if (!selectedAvailability) return [];
|
|
@@ -2894,6 +2971,13 @@ export function AdminChangeBookingFlow({
|
|
|
2894
2971
|
);
|
|
2895
2972
|
return {
|
|
2896
2973
|
kind: 'ticket',
|
|
2974
|
+
lineKey:
|
|
2975
|
+
showProviderPricingInlineEditor
|
|
2976
|
+
? providerEditableLineByNormalizedLabel.get(normalizeReceiptLabel(line.category))?.lineKey
|
|
2977
|
+
: undefined,
|
|
2978
|
+
editable:
|
|
2979
|
+
showProviderPricingInlineEditor &&
|
|
2980
|
+
providerEditableLineByNormalizedLabel.has(normalizeReceiptLabel(line.category)),
|
|
2897
2981
|
category: line.category,
|
|
2898
2982
|
qty: line.qty,
|
|
2899
2983
|
itemTotal: line.itemTotal,
|
|
@@ -2928,6 +3012,25 @@ export function AdminChangeBookingFlow({
|
|
|
2928
3012
|
fee.name.toLowerCase().includes('license'));
|
|
2929
3013
|
return {
|
|
2930
3014
|
kind: 'line' as const,
|
|
3015
|
+
lineKey:
|
|
3016
|
+
showProviderPricingInlineEditor
|
|
3017
|
+
? providerEditableLineByNormalizedLabel.get(
|
|
3018
|
+
normalizeReceiptLabel(
|
|
3019
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
3020
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
3021
|
+
: fee.name
|
|
3022
|
+
)
|
|
3023
|
+
)?.lineKey
|
|
3024
|
+
: undefined,
|
|
3025
|
+
editable:
|
|
3026
|
+
showProviderPricingInlineEditor &&
|
|
3027
|
+
providerEditableLineByNormalizedLabel.has(
|
|
3028
|
+
normalizeReceiptLabel(
|
|
3029
|
+
feeLineItems.some((f) => f.name === fee.name)
|
|
3030
|
+
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
3031
|
+
: fee.name
|
|
3032
|
+
)
|
|
3033
|
+
),
|
|
2931
3034
|
label: feeLineItems.some((f) => f.name === fee.name)
|
|
2932
3035
|
? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
|
|
2933
3036
|
: fee.name,
|
|
@@ -2953,6 +3056,8 @@ export function AdminChangeBookingFlow({
|
|
|
2953
3056
|
effectiveCancellationPolicyLabel,
|
|
2954
3057
|
feeLineItemsWithAddOns,
|
|
2955
3058
|
feeLineItems,
|
|
3059
|
+
showProviderPricingInlineEditor,
|
|
3060
|
+
providerEditableLineByNormalizedLabel,
|
|
2956
3061
|
]);
|
|
2957
3062
|
|
|
2958
3063
|
const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
|
|
@@ -3456,7 +3561,8 @@ export function AdminChangeBookingFlow({
|
|
|
3456
3561
|
countsChanged ||
|
|
3457
3562
|
addOnsChanged ||
|
|
3458
3563
|
returnChanged,
|
|
3459
|
-
// Authoritative for "real user change" gating
|
|
3564
|
+
// Authoritative for "real user change" gating in provider dashboard:
|
|
3565
|
+
// ignore option-id noise and only consider user-visible booking deltas.
|
|
3460
3566
|
hasOperationalChangesFromInitial:
|
|
3461
3567
|
dateChanged ||
|
|
3462
3568
|
pickupChanged ||
|
|
@@ -3483,28 +3589,62 @@ export function AdminChangeBookingFlow({
|
|
|
3483
3589
|
addOnSelections,
|
|
3484
3590
|
initialAddOnMinQtyByKey,
|
|
3485
3591
|
]);
|
|
3486
|
-
const hasChangeSelection =
|
|
3592
|
+
const hasChangeSelection =
|
|
3593
|
+
isProviderDashboardChange
|
|
3594
|
+
? changeSelectionDetails.hasOperationalChangesFromInitial
|
|
3595
|
+
: changeSelectionDetails.hasChangesFromInitial;
|
|
3487
3596
|
|
|
3488
3597
|
const changeFlowNeedsServerPrice =
|
|
3598
|
+
isCustomerSelfServeChange &&
|
|
3489
3599
|
hasChangeSelection &&
|
|
3490
3600
|
!!initialValues?.bookingReference?.trim() &&
|
|
3491
3601
|
!!lastName.trim();
|
|
3492
3602
|
|
|
3493
|
-
const isChangeQuoteBlocked = latestChangeQuote?.canProceed === false;
|
|
3494
|
-
const requiresReturnInChangeFlow = !!initialValues?.returnAvailabilityId?.trim();
|
|
3603
|
+
const isChangeQuoteBlocked = isCustomerSelfServeChange && latestChangeQuote?.canProceed === false;
|
|
3604
|
+
const requiresReturnInChangeFlow = isCustomerSelfServeChange && !!initialValues?.returnAvailabilityId?.trim();
|
|
3495
3605
|
const missingRequiredReturnSelection = requiresReturnInChangeFlow && !selectedReturnOption;
|
|
3496
3606
|
|
|
3497
3607
|
const changeFlowSubmitDisabled =
|
|
3498
3608
|
missingRequiredReturnSelection ||
|
|
3499
|
-
(
|
|
3609
|
+
(isCustomerSelfServeChange &&
|
|
3610
|
+
changeFlowNeedsServerPrice &&
|
|
3500
3611
|
(changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)));
|
|
3501
3612
|
|
|
3502
|
-
const
|
|
3613
|
+
const providerTotalsPreview =
|
|
3614
|
+
showProviderPricingInlineEditor && providerPricingUi?.totalsPreview
|
|
3615
|
+
? providerPricingUi.totalsPreview
|
|
3616
|
+
: null;
|
|
3617
|
+
const providerHasEditedLineOverrides =
|
|
3618
|
+
isProviderDashboardChange &&
|
|
3619
|
+
providerQuotedLines.some((line) => {
|
|
3620
|
+
if (!isManualOverrideEligibleLine(line)) return false;
|
|
3621
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
3622
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
3623
|
+
if (!Number.isFinite(parsed)) return false;
|
|
3624
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3625
|
+
return Math.abs(rounded - line.amount) > 0.0001;
|
|
3626
|
+
});
|
|
3627
|
+
const providerHasAdditionalAdjustments =
|
|
3628
|
+
isProviderDashboardChange &&
|
|
3629
|
+
(providerPricingUi?.additionalAdjustments ?? []).some((adj) => {
|
|
3630
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3631
|
+
const hasAmount = Number.isFinite(parsed) && parsed > 0;
|
|
3632
|
+
const currentAmount = hasAmount ? (Math.round(parsed * 100) / 100).toFixed(2) : '';
|
|
3633
|
+
const originalAmount = adj.originalAmountInput ?? '';
|
|
3634
|
+
const currentLabel = (adj.label ?? '').trim();
|
|
3635
|
+
const originalLabel = (adj.originalLabel ?? '').trim();
|
|
3636
|
+
const currentMode = adj.mode;
|
|
3637
|
+
const originalMode = adj.originalMode;
|
|
3638
|
+
if (!originalMode) return hasAmount || currentLabel.length > 0;
|
|
3639
|
+
return currentAmount !== originalAmount || currentLabel !== originalLabel || currentMode !== originalMode;
|
|
3640
|
+
});
|
|
3641
|
+
const hasEffectiveChangeSelection =
|
|
3642
|
+
hasChangeSelection || providerHasEditedLineOverrides || providerHasAdditionalAdjustments;
|
|
3503
3643
|
|
|
3504
3644
|
const displayedChangeAmounts = resolveChangeFlowDisplayedAmounts({
|
|
3505
|
-
providerPreview:
|
|
3645
|
+
providerPreview: providerTotalsPreview,
|
|
3506
3646
|
serverQuotePreview:
|
|
3507
|
-
latestChangeQuote?.serverDisplay
|
|
3647
|
+
isCustomerSelfServeChange && latestChangeQuote?.serverDisplay
|
|
3508
3648
|
? latestChangeQuote.serverDisplay
|
|
3509
3649
|
: null,
|
|
3510
3650
|
fromCart: {
|
|
@@ -3520,13 +3660,13 @@ export function AdminChangeBookingFlow({
|
|
|
3520
3660
|
const changeFlowClientEstimateDue = (() => {
|
|
3521
3661
|
if (!originalReceipt) return totalPrice;
|
|
3522
3662
|
// Customer self-serve: amount due comes from POST .../change/quote (`amountDueCents` / priceDiff), not FE delta math.
|
|
3523
|
-
if (latestChangeQuote != null && !changeQuoteFetchError) {
|
|
3663
|
+
if (isCustomerSelfServeChange && latestChangeQuote != null && !changeQuoteFetchError) {
|
|
3524
3664
|
return normalizeNearZeroOwed(latestChangeQuote.priceDiff);
|
|
3525
3665
|
}
|
|
3526
3666
|
return changeFlowBalanceVsOriginal({
|
|
3527
3667
|
newTotal: displayChangeFlowProposedTotal,
|
|
3528
3668
|
originalReceiptTotal: originalReceipt.total,
|
|
3529
|
-
audience: 'customer',
|
|
3669
|
+
audience: isProviderDashboardChange ? 'provider' : 'customer',
|
|
3530
3670
|
});
|
|
3531
3671
|
})();
|
|
3532
3672
|
|
|
@@ -3535,6 +3675,14 @@ export function AdminChangeBookingFlow({
|
|
|
3535
3675
|
|
|
3536
3676
|
const changeCheckoutButtonLabel = (() => {
|
|
3537
3677
|
if (!hasEffectiveChangeSelection) return undefined;
|
|
3678
|
+
if (isProviderDashboardChange) {
|
|
3679
|
+
const est = Math.round(changeFlowClientEstimateDue * 100) / 100;
|
|
3680
|
+
return est > 0
|
|
3681
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3682
|
+
: est < 0
|
|
3683
|
+
? `Change booking (${formatCurrencyAmount(est, currency, locale as 'en' | 'fr')})`
|
|
3684
|
+
: 'Change booking (no charge)';
|
|
3685
|
+
}
|
|
3538
3686
|
if (changeFlowNeedsServerPrice) {
|
|
3539
3687
|
if (changeQuoteLoading) {
|
|
3540
3688
|
const tr = t('booking.updatingPrice');
|
|
@@ -3568,8 +3716,39 @@ export function AdminChangeBookingFlow({
|
|
|
3568
3716
|
const checkoutFormError =
|
|
3569
3717
|
(error || '') ||
|
|
3570
3718
|
(missingRequiredReturnSelection ? 'Removing return option in self-serve is not available. Please contact support.' : '') ||
|
|
3571
|
-
(isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
3572
|
-
(changeQuoteFetchError ?? '');
|
|
3719
|
+
(isCustomerSelfServeChange && isChangeQuoteBlocked ? (latestChangeQuote?.reasonIfBlocked ?? '') : '') ||
|
|
3720
|
+
(isCustomerSelfServeChange ? changeQuoteFetchError ?? '' : '');
|
|
3721
|
+
|
|
3722
|
+
const providerPricingOverrides =
|
|
3723
|
+
isProviderDashboardChange && providerQuotedLines.length > 0
|
|
3724
|
+
? providerQuotedLines
|
|
3725
|
+
.filter((line) => isManualOverrideEligibleLine(line))
|
|
3726
|
+
.map((line) => {
|
|
3727
|
+
const raw = providerPricingUi?.lineAmountInputs?.[line.lineKey];
|
|
3728
|
+
const parsed = raw == null || raw.trim() === '' ? line.amount : Number(raw);
|
|
3729
|
+
if (!Number.isFinite(parsed)) return null;
|
|
3730
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3731
|
+
return Math.abs(rounded - line.amount) > 0.0001
|
|
3732
|
+
? { lineKey: line.lineKey, amount: rounded, reason: 'Provider dashboard override' }
|
|
3733
|
+
: null;
|
|
3734
|
+
})
|
|
3735
|
+
.filter((v): v is { lineKey: string; amount: number; reason: string } => v != null)
|
|
3736
|
+
: [];
|
|
3737
|
+
const providerAdditionalAdjustments =
|
|
3738
|
+
isProviderDashboardChange
|
|
3739
|
+
? (providerPricingUi?.additionalAdjustments ?? [])
|
|
3740
|
+
.map((adj) => {
|
|
3741
|
+
const parsed = Number((adj.amountInput ?? '').trim());
|
|
3742
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
3743
|
+
const rounded = Math.round(parsed * 100) / 100;
|
|
3744
|
+
const signed = adj.mode === 'DISCOUNT' ? -rounded : rounded;
|
|
3745
|
+
return {
|
|
3746
|
+
label: adj.label?.trim() || (signed < 0 ? 'Provider discount' : 'Provider charge'),
|
|
3747
|
+
amount: signed,
|
|
3748
|
+
};
|
|
3749
|
+
})
|
|
3750
|
+
.filter((v): v is { label: string; amount: number } => v != null)
|
|
3751
|
+
: [];
|
|
3573
3752
|
|
|
3574
3753
|
const changeFlowSelectionPreview = useMemo((): ChangeFlowSelectionPreview | null => {
|
|
3575
3754
|
if (!selectedAvailability || totalQuantity <= 0) return null;
|
|
@@ -3817,7 +3996,8 @@ export function AdminChangeBookingFlow({
|
|
|
3817
3996
|
timesForSelectedDate,
|
|
3818
3997
|
anchor,
|
|
3819
3998
|
companyTimezone,
|
|
3820
|
-
optionsMap
|
|
3999
|
+
optionsMap,
|
|
4000
|
+
isAdmin
|
|
3821
4001
|
);
|
|
3822
4002
|
changeFlowOutboundAnchorRef.current = null;
|
|
3823
4003
|
if (matched) {
|
|
@@ -3908,7 +4088,8 @@ export function AdminChangeBookingFlow({
|
|
|
3908
4088
|
const fromAnchor = pickReturnMatchingPreviousSelection(
|
|
3909
4089
|
sorted,
|
|
3910
4090
|
returnAnchor,
|
|
3911
|
-
companyTimezone
|
|
4091
|
+
companyTimezone,
|
|
4092
|
+
isAdmin
|
|
3912
4093
|
);
|
|
3913
4094
|
changeFlowReturnAnchorRef.current = null;
|
|
3914
4095
|
if (fromAnchor) {
|
|
@@ -4231,6 +4412,8 @@ export function AdminChangeBookingFlow({
|
|
|
4231
4412
|
return;
|
|
4232
4413
|
}
|
|
4233
4414
|
|
|
4415
|
+
const skipContactFields = isProviderDashboardChange;
|
|
4416
|
+
if (!skipContactFields) {
|
|
4234
4417
|
// Validate email (required)
|
|
4235
4418
|
if (!email) {
|
|
4236
4419
|
setError(t('booking.enterEmail') || 'Please enter your email address');
|
|
@@ -4253,6 +4436,7 @@ export function AdminChangeBookingFlow({
|
|
|
4253
4436
|
setError(t('booking.enterLastName') || 'Please enter your last name');
|
|
4254
4437
|
return;
|
|
4255
4438
|
}
|
|
4439
|
+
}
|
|
4256
4440
|
|
|
4257
4441
|
// Allow checkout if pickup location is selected OR if user chose "I don't know"
|
|
4258
4442
|
if (product.pickupLocations && product.pickupLocations.length > 0 && !pickupLocationId && !pickupLocationSkipped) {
|
|
@@ -4279,6 +4463,45 @@ export function AdminChangeBookingFlow({
|
|
|
4279
4463
|
return;
|
|
4280
4464
|
}
|
|
4281
4465
|
|
|
4466
|
+
if (onChangeBooking) {
|
|
4467
|
+
const pickupForChange = pickupLocationId
|
|
4468
|
+
? product.pickupLocations?.find((loc) => loc.id === pickupLocationId)
|
|
4469
|
+
: null;
|
|
4470
|
+
await onChangeBooking({
|
|
4471
|
+
productId: availabilityProductOptionId,
|
|
4472
|
+
dateTime: selectedAvailability.dateTime,
|
|
4473
|
+
bookingItems,
|
|
4474
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
|
|
4475
|
+
pickupLocationId: pickupLocationId ?? null,
|
|
4476
|
+
travelerHotel: pickupForChange?.name ?? null,
|
|
4477
|
+
startTime: selectedAvailability.dateTime ?? null,
|
|
4478
|
+
passengerCount: null,
|
|
4479
|
+
childSafetySeatsCount: null,
|
|
4480
|
+
foodRestrictions: null,
|
|
4481
|
+
addOnSelections: addOnSelections.length > 0 ? addOnSelections : null,
|
|
4482
|
+
cancellationPolicyId: cancellationPolicyId ?? initialValues?.cancellationPolicyId ?? null,
|
|
4483
|
+
promoCode: appliedPromoCode ?? null,
|
|
4484
|
+
newTotalAmount: displayChangeFlowProposedTotal,
|
|
4485
|
+
additionalHoursCount: null,
|
|
4486
|
+
pricingAdjustment:
|
|
4487
|
+
providerPricingOverrides.length > 0 || providerAdditionalAdjustments.length > 0
|
|
4488
|
+
? {
|
|
4489
|
+
mode: 'MANUAL_LINES',
|
|
4490
|
+
lineOverrides: providerPricingOverrides,
|
|
4491
|
+
additionalAdjustments: providerAdditionalAdjustments,
|
|
4492
|
+
}
|
|
4493
|
+
: undefined,
|
|
4494
|
+
capacitySeatCredit: {
|
|
4495
|
+
enabled: true,
|
|
4496
|
+
previousPassengerCount: changeFlowInitialTicketCount,
|
|
4497
|
+
previousAvailabilityId: initialValues?.availabilityId ?? null,
|
|
4498
|
+
previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
|
|
4499
|
+
},
|
|
4500
|
+
});
|
|
4501
|
+
setLoading(false);
|
|
4502
|
+
return;
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4282
4505
|
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
4283
4506
|
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
4284
4507
|
product.productId,
|
|
@@ -4296,6 +4519,7 @@ export function AdminChangeBookingFlow({
|
|
|
4296
4519
|
let changeIntentIdForCheckout: string | undefined;
|
|
4297
4520
|
let changeBookingReferenceForPaidFlow: string | undefined;
|
|
4298
4521
|
|
|
4522
|
+
if (isCustomerSelfServeChange) {
|
|
4299
4523
|
const changeBookingReference = initialValues?.bookingReference?.trim();
|
|
4300
4524
|
const changeLastName = lastName.trim();
|
|
4301
4525
|
if (!changeBookingReference || !changeLastName) {
|
|
@@ -4383,12 +4607,13 @@ export function AdminChangeBookingFlow({
|
|
|
4383
4607
|
if (!changeIntentIdForCheckout) {
|
|
4384
4608
|
throw new Error('Missing change intent for payment.');
|
|
4385
4609
|
}
|
|
4610
|
+
}
|
|
4386
4611
|
|
|
4387
4612
|
pendingReservationRef.current = null;
|
|
4388
4613
|
|
|
4389
4614
|
// Note: Do NOT call onSuccess here for paid bookings — we're about to show the Stripe
|
|
4390
4615
|
// CheckoutModal. onSuccess (e.g. closing the parent dialog) should only run when we're
|
|
4391
|
-
// actually done (free booking redirect). Calling it here
|
|
4616
|
+
// actually done (free booking redirect, admin confirm-without-payment). Calling it here
|
|
4392
4617
|
// would close the dialog before the payment modal opens.
|
|
4393
4618
|
|
|
4394
4619
|
// Update stored booking data (no holds reservation — change flow keys off booking reference elsewhere).
|
|
@@ -4414,11 +4639,13 @@ export function AdminChangeBookingFlow({
|
|
|
4414
4639
|
// Build checkout breakdown from the exact same values we show in the UI and Stripe modal.
|
|
4415
4640
|
// Backend will charge totalAmount and store this as the receipt so /manage matches.
|
|
4416
4641
|
const taxForBreakdown = effectivePromoDiscountAmount > 0 ? effectiveTax : tax;
|
|
4417
|
-
const amountDueForCheckout =
|
|
4642
|
+
const amountDueForCheckout = isCustomerSelfServeChange
|
|
4643
|
+
? changeFlowBalanceVsOriginal({
|
|
4418
4644
|
newTotal: changeFlowNewBookingTotal,
|
|
4419
4645
|
originalReceiptTotal: originalReceipt?.total ?? 0,
|
|
4420
4646
|
audience: 'customer',
|
|
4421
|
-
})
|
|
4647
|
+
})
|
|
4648
|
+
: totalPrice;
|
|
4422
4649
|
const lines = [
|
|
4423
4650
|
...ticketLineItemsForChangeFlowDisplay.map((line) => ({
|
|
4424
4651
|
label: line.category,
|
|
@@ -4495,7 +4722,8 @@ export function AdminChangeBookingFlow({
|
|
|
4495
4722
|
roundingLabel: t('booking.rounding') || 'Rounding',
|
|
4496
4723
|
});
|
|
4497
4724
|
|
|
4498
|
-
const paymentIntent =
|
|
4725
|
+
const paymentIntent = isCustomerSelfServeChange
|
|
4726
|
+
? await createChangeBookingPaymentIntent(
|
|
4499
4727
|
(() => {
|
|
4500
4728
|
const id = changeIntentIdForCheckout ?? latestChangeQuote?.changeIntentId;
|
|
4501
4729
|
if (!id) {
|
|
@@ -4503,7 +4731,76 @@ export function AdminChangeBookingFlow({
|
|
|
4503
4731
|
}
|
|
4504
4732
|
return id;
|
|
4505
4733
|
})()
|
|
4506
|
-
)
|
|
4734
|
+
)
|
|
4735
|
+
: await createPaymentIntent({
|
|
4736
|
+
productId: product.productId,
|
|
4737
|
+
optionId: availabilityProductOptionId,
|
|
4738
|
+
date: datePart,
|
|
4739
|
+
time: timePart,
|
|
4740
|
+
quantity: totalQuantity,
|
|
4741
|
+
customerEmail: email,
|
|
4742
|
+
customerFirstName: firstName.trim() || undefined,
|
|
4743
|
+
customerLastName: lastName.trim() || undefined,
|
|
4744
|
+
currency: currency,
|
|
4745
|
+
travelerHotel: selectedPickupLocation?.name || undefined,
|
|
4746
|
+
pickupLocationId: pickupLocationId || undefined,
|
|
4747
|
+
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4748
|
+
returnAvailabilityId: selectedReturnOption?.returnAvailabilityId,
|
|
4749
|
+
promoCode: (lockedPromoCode || appliedPromoCode) || undefined,
|
|
4750
|
+
cancellationPolicyId: cancellationPolicyId || undefined,
|
|
4751
|
+
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
4752
|
+
checkoutBreakdown,
|
|
4753
|
+
skipConfirmationCommunications: isAdmin && skipConfirmationCommunications ? true : undefined,
|
|
4754
|
+
disableAutoCommunications: isAdmin && disableAutoCommunications ? true : undefined,
|
|
4755
|
+
...bookingSourceContext,
|
|
4756
|
+
});
|
|
4757
|
+
|
|
4758
|
+
// Admin: show choice to pay now or confirm without payment (customer owes full balance)
|
|
4759
|
+
if (isAdmin) {
|
|
4760
|
+
const adminReservationRef =
|
|
4761
|
+
initialValues?.bookingReference?.trim() ??
|
|
4762
|
+
changeBookingReferenceForPaidFlow ??
|
|
4763
|
+
'';
|
|
4764
|
+
if (!adminReservationRef) {
|
|
4765
|
+
throw new Error('Missing reservation reference for admin payment flow');
|
|
4766
|
+
}
|
|
4767
|
+
setError('');
|
|
4768
|
+
setAdminChoiceData({
|
|
4769
|
+
reservationReference: adminReservationRef,
|
|
4770
|
+
reservationExpiration: undefined,
|
|
4771
|
+
checkoutBreakdown,
|
|
4772
|
+
totalAmount: amountDueForCheckout,
|
|
4773
|
+
datePart,
|
|
4774
|
+
timePart,
|
|
4775
|
+
availabilityProductOptionId,
|
|
4776
|
+
itineraryDisplay: itineraryDisplay ?? undefined,
|
|
4777
|
+
clientSecret: paymentIntent.clientSecret ?? '',
|
|
4778
|
+
ticketLinesForModal: ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4779
|
+
const rate = pricing.find((r) => r.category === line.category);
|
|
4780
|
+
const breakdown = getPriceBreakdown(
|
|
4781
|
+
line.category,
|
|
4782
|
+
rate?.priceCAD ?? 0,
|
|
4783
|
+
rate?.baseInDisplayCurrency,
|
|
4784
|
+
rate?.appliedAdjustments ?? []
|
|
4785
|
+
);
|
|
4786
|
+
return { line, breakdown };
|
|
4787
|
+
}),
|
|
4788
|
+
feeLineItems: feeLineItemsWithAddOns,
|
|
4789
|
+
returnPriceAdjustment: checkoutReturnLineAmount,
|
|
4790
|
+
cancellationPolicyFee,
|
|
4791
|
+
cancellationPolicyLabel: effectiveCancellationPolicyLabel,
|
|
4792
|
+
subtotal: effectiveSubtotal,
|
|
4793
|
+
tax: effectivePromoDiscountAmount > 0 ? effectiveTax : tax,
|
|
4794
|
+
totalQuantity,
|
|
4795
|
+
isTaxIncludedInPrice,
|
|
4796
|
+
taxRate: pricingConfig?.taxRate ?? 0,
|
|
4797
|
+
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
4798
|
+
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
4799
|
+
});
|
|
4800
|
+
setShowAdminPaymentChoice(true);
|
|
4801
|
+
setLoading(false);
|
|
4802
|
+
return;
|
|
4803
|
+
}
|
|
4507
4804
|
|
|
4508
4805
|
const ticketLinesForModal: CheckoutModalLineItem[] = ticketLineItemsForChangeFlowDisplay.map((line) => {
|
|
4509
4806
|
const rate = pricing.find((r) => r.category === line.category);
|
|
@@ -4525,7 +4822,7 @@ export function AdminChangeBookingFlow({
|
|
|
4525
4822
|
// Paid change: always return to stable ref+lastName + explicit intent (not reservationRef).
|
|
4526
4823
|
// /manage-booking runs bounded refresh only when `from=change_payment` (see manage-booking page).
|
|
4527
4824
|
successUrlOverride:
|
|
4528
|
-
changeBookingReferenceForPaidFlow
|
|
4825
|
+
isCustomerSelfServeChange && changeBookingReferenceForPaidFlow
|
|
4529
4826
|
? (() => {
|
|
4530
4827
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
4531
4828
|
const ref = encodeURIComponent(
|
|
@@ -4553,7 +4850,7 @@ export function AdminChangeBookingFlow({
|
|
|
4553
4850
|
promoDiscountAmount: effectivePromoDiscountAmount > 0 ? effectivePromoDiscountAmount : 0,
|
|
4554
4851
|
discountLabel: appliedPromoCode ? `Promo: ${appliedPromoCode}` : (originalReceipt?.promoLabel || undefined),
|
|
4555
4852
|
changeTotals:
|
|
4556
|
-
originalReceipt
|
|
4853
|
+
isCustomerSelfServeChange && originalReceipt
|
|
4557
4854
|
? {
|
|
4558
4855
|
previousTotal: originalReceipt.total,
|
|
4559
4856
|
newTotal: displayChangeFlowProposedTotal,
|
|
@@ -4622,6 +4919,86 @@ export function AdminChangeBookingFlow({
|
|
|
4622
4919
|
}
|
|
4623
4920
|
};
|
|
4624
4921
|
|
|
4922
|
+
const handleConfirmWithoutPayment = async () => {
|
|
4923
|
+
if (!adminChoiceData) return;
|
|
4924
|
+
setLoading(true);
|
|
4925
|
+
setError('');
|
|
4926
|
+
try {
|
|
4927
|
+
const bookingSourceContext = buildBookingSourceContext(bookingSourceAttribution, {
|
|
4928
|
+
clientChannelSource: inferClientBookingSourceFromProductIds(
|
|
4929
|
+
product.productId,
|
|
4930
|
+
adminChoiceData.availabilityProductOptionId,
|
|
4931
|
+
),
|
|
4932
|
+
forcePartnerPortalChannel: partnerPortalBooking,
|
|
4933
|
+
forceDashboardSource: bookingAppMode === 'provider-dashboard',
|
|
4934
|
+
});
|
|
4935
|
+
const result = await confirmBookingWithoutPayment({
|
|
4936
|
+
reservationReference: adminChoiceData.reservationReference,
|
|
4937
|
+
productId: product.productId,
|
|
4938
|
+
optionId: adminChoiceData.availabilityProductOptionId,
|
|
4939
|
+
date: adminChoiceData.datePart,
|
|
4940
|
+
time: adminChoiceData.timePart,
|
|
4941
|
+
customerEmail: email || undefined,
|
|
4942
|
+
customerFirstName: firstName.trim() || undefined,
|
|
4943
|
+
customerLastName: lastName.trim() || undefined,
|
|
4944
|
+
currency: currency,
|
|
4945
|
+
travelerHotel: product.pickupLocations?.find(loc => loc.id === pickupLocationId)?.name || undefined,
|
|
4946
|
+
pickupLocationId: pickupLocationId || undefined,
|
|
4947
|
+
itineraryDisplay: adminChoiceData.itineraryDisplay ?? undefined,
|
|
4948
|
+
termsAcceptedAt: termsAcceptedAt ?? undefined,
|
|
4949
|
+
skipConfirmationCommunications: skipConfirmationCommunications ? true : undefined,
|
|
4950
|
+
disableAutoCommunications: disableAutoCommunications ? true : undefined,
|
|
4951
|
+
checkoutBreakdown: adminChoiceData.checkoutBreakdown,
|
|
4952
|
+
depositAmount: 0,
|
|
4953
|
+
balanceAmount: adminChoiceData.totalAmount,
|
|
4954
|
+
totalAmount: adminChoiceData.totalAmount,
|
|
4955
|
+
...bookingSourceContext,
|
|
4956
|
+
});
|
|
4957
|
+
pendingReservationRef.current = null;
|
|
4958
|
+
const ref = formatBookingRefForDisplay(result.bookingReference);
|
|
4959
|
+
const ln = lastName.trim();
|
|
4960
|
+
setShowAdminPaymentChoice(false);
|
|
4961
|
+
setAdminChoiceData(null);
|
|
4962
|
+
onSuccess?.({ reservationReference: adminChoiceData.reservationReference });
|
|
4963
|
+
if (onShowManage) {
|
|
4964
|
+
onShowManage({ ref, lastName: ln });
|
|
4965
|
+
} else {
|
|
4966
|
+
const params = new URLSearchParams({ ref, lastName: ln, booking_complete: '1' });
|
|
4967
|
+
window.location.href = `/manage-booking?${params.toString()}`;
|
|
4968
|
+
}
|
|
4969
|
+
} catch (err) {
|
|
4970
|
+
setError(err instanceof Error ? err.message : 'Failed to confirm booking');
|
|
4971
|
+
} finally {
|
|
4972
|
+
setLoading(false);
|
|
4973
|
+
}
|
|
4974
|
+
};
|
|
4975
|
+
|
|
4976
|
+
const handlePayNow = () => {
|
|
4977
|
+
if (!adminChoiceData) return;
|
|
4978
|
+
setShowAdminPaymentChoice(false);
|
|
4979
|
+
setCheckoutClientSecret(adminChoiceData.clientSecret);
|
|
4980
|
+
setCheckoutModalData({
|
|
4981
|
+
reservationReference: adminChoiceData.reservationReference,
|
|
4982
|
+
reservationExpiration: adminChoiceData.reservationExpiration,
|
|
4983
|
+
customerLastName: lastName.trim(),
|
|
4984
|
+
ticketLines: adminChoiceData.ticketLinesForModal,
|
|
4985
|
+
feeLineItems: adminChoiceData.feeLineItems,
|
|
4986
|
+
returnPriceAdjustment: adminChoiceData.returnPriceAdjustment,
|
|
4987
|
+
cancellationPolicyFee: adminChoiceData.cancellationPolicyFee,
|
|
4988
|
+
cancellationPolicyLabel: adminChoiceData.cancellationPolicyLabel,
|
|
4989
|
+
subtotal: adminChoiceData.subtotal,
|
|
4990
|
+
tax: adminChoiceData.tax,
|
|
4991
|
+
total: adminChoiceData.totalAmount,
|
|
4992
|
+
totalQuantity: adminChoiceData.totalQuantity,
|
|
4993
|
+
isTaxIncludedInPrice: adminChoiceData.isTaxIncludedInPrice,
|
|
4994
|
+
taxRate: adminChoiceData.taxRate,
|
|
4995
|
+
promoDiscountAmount: adminChoiceData.promoDiscountAmount,
|
|
4996
|
+
discountLabel: adminChoiceData.discountLabel,
|
|
4997
|
+
});
|
|
4998
|
+
setShowCheckoutModal(true);
|
|
4999
|
+
setAdminChoiceData(null);
|
|
5000
|
+
};
|
|
5001
|
+
|
|
4625
5002
|
if (activeOptions.length === 0) {
|
|
4626
5003
|
return (
|
|
4627
5004
|
<div className="flex items-center justify-center py-16">
|
|
@@ -4632,6 +5009,17 @@ export function AdminChangeBookingFlow({
|
|
|
4632
5009
|
|
|
4633
5010
|
return (
|
|
4634
5011
|
<div className="booking-flow-root space-y-8">
|
|
5012
|
+
{/* Admin: choose to pay now or confirm without payment (full balance owed) */}
|
|
5013
|
+
<AdminPaymentChoiceModal
|
|
5014
|
+
open={!!(showAdminPaymentChoice && adminChoiceData)}
|
|
5015
|
+
totalAmount={adminChoiceData?.totalAmount ?? 0}
|
|
5016
|
+
currency={currency}
|
|
5017
|
+
loading={loading}
|
|
5018
|
+
error={error}
|
|
5019
|
+
onPayNow={handlePayNow}
|
|
5020
|
+
onConfirmWithoutPayment={handleConfirmWithoutPayment}
|
|
5021
|
+
onCancel={() => { setShowAdminPaymentChoice(false); setAdminChoiceData(null); setError(''); }}
|
|
5022
|
+
/>
|
|
4635
5023
|
{checkoutModalData && (
|
|
4636
5024
|
<CheckoutModal
|
|
4637
5025
|
open={showCheckoutModal}
|
|
@@ -4886,8 +5274,92 @@ export function AdminChangeBookingFlow({
|
|
|
4886
5274
|
currency={currency}
|
|
4887
5275
|
locale={locale}
|
|
4888
5276
|
t={t}
|
|
4889
|
-
extraBetweenTaxAndTotal={
|
|
4890
|
-
|
|
5277
|
+
extraBetweenTaxAndTotal={
|
|
5278
|
+
<>
|
|
5279
|
+
{showProviderPricingInlineEditor && providerPricingUi?.error ? (
|
|
5280
|
+
<div className="mt-2 text-sm text-red-700">{providerPricingUi.error}</div>
|
|
5281
|
+
) : null}
|
|
5282
|
+
{showProviderPricingInlineEditor &&
|
|
5283
|
+
providerPricingUi?.loading &&
|
|
5284
|
+
providerQuotedLines.length === 0 ? (
|
|
5285
|
+
<div className="mt-2 text-sm text-stone-500">Loading price lines...</div>
|
|
5286
|
+
) : null}
|
|
5287
|
+
{showProviderPricingInlineEditor &&
|
|
5288
|
+
providerPricingUi?.helperText &&
|
|
5289
|
+
!providerPricingUi.error ? (
|
|
5290
|
+
<div className="mt-2 text-xs text-stone-500">{providerPricingUi.helperText}</div>
|
|
5291
|
+
) : null}
|
|
5292
|
+
</>
|
|
5293
|
+
}
|
|
5294
|
+
extraBeforeSubtotal={
|
|
5295
|
+
showProviderPricingInlineEditor && (providerPricingUi?.additionalAdjustments?.length ?? 0) > 0 ? (
|
|
5296
|
+
<div className="space-y-1">
|
|
5297
|
+
{providerPricingUi?.additionalAdjustments?.map((adj) => (
|
|
5298
|
+
<div key={adj.id} className="flex items-center justify-between gap-2 text-sm">
|
|
5299
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
5300
|
+
<button
|
|
5301
|
+
type="button"
|
|
5302
|
+
className="rounded border border-stone-300 px-1 text-xs text-stone-600 hover:bg-stone-100"
|
|
5303
|
+
onClick={() => providerPricingUi?.onRemoveAdditionalAdjustment?.(adj.id)}
|
|
5304
|
+
>
|
|
5305
|
+
-
|
|
5306
|
+
</button>
|
|
5307
|
+
<input
|
|
5308
|
+
type="text"
|
|
5309
|
+
className="w-40 rounded border border-stone-300 px-2 py-0.5 text-sm"
|
|
5310
|
+
placeholder="Line description"
|
|
5311
|
+
value={adj.label}
|
|
5312
|
+
onChange={(e) =>
|
|
5313
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, { label: e.target.value })
|
|
5314
|
+
}
|
|
5315
|
+
/>
|
|
5316
|
+
</div>
|
|
5317
|
+
<div className="flex items-center gap-1">
|
|
5318
|
+
<select
|
|
5319
|
+
className="rounded border border-stone-300 px-1 py-0.5 text-xs"
|
|
5320
|
+
value={adj.mode}
|
|
5321
|
+
onChange={(e) =>
|
|
5322
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
5323
|
+
mode: e.target.value as 'DISCOUNT' | 'CHARGE',
|
|
5324
|
+
})
|
|
5325
|
+
}
|
|
5326
|
+
>
|
|
5327
|
+
<option value="DISCOUNT">-</option>
|
|
5328
|
+
<option value="CHARGE">+</option>
|
|
5329
|
+
</select>
|
|
5330
|
+
<input
|
|
5331
|
+
type="text"
|
|
5332
|
+
inputMode="decimal"
|
|
5333
|
+
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"
|
|
5334
|
+
placeholder="0.00"
|
|
5335
|
+
value={adj.amountInput}
|
|
5336
|
+
onChange={(e) =>
|
|
5337
|
+
providerPricingUi?.onUpdateAdditionalAdjustment?.(adj.id, {
|
|
5338
|
+
amountInput: e.target.value,
|
|
5339
|
+
})
|
|
5340
|
+
}
|
|
5341
|
+
/>
|
|
5342
|
+
</div>
|
|
5343
|
+
</div>
|
|
5344
|
+
))}
|
|
5345
|
+
<button
|
|
5346
|
+
type="button"
|
|
5347
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
5348
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
5349
|
+
>
|
|
5350
|
+
+ add line item
|
|
5351
|
+
</button>
|
|
5352
|
+
</div>
|
|
5353
|
+
) : showProviderPricingInlineEditor ? (
|
|
5354
|
+
<button
|
|
5355
|
+
type="button"
|
|
5356
|
+
className="rounded border border-stone-300 px-2 py-0.5 text-xs text-stone-600 hover:bg-stone-100"
|
|
5357
|
+
onClick={() => providerPricingUi?.onAddAdditionalAdjustment?.()}
|
|
5358
|
+
>
|
|
5359
|
+
+ add line item
|
|
5360
|
+
</button>
|
|
5361
|
+
) : undefined
|
|
5362
|
+
}
|
|
4891
5363
|
firstName={firstName}
|
|
4892
5364
|
lastName={lastName}
|
|
4893
5365
|
email={email}
|
|
@@ -4928,12 +5400,12 @@ export function AdminChangeBookingFlow({
|
|
|
4928
5400
|
setTermsAccepted(checked);
|
|
4929
5401
|
setTermsAcceptedAt(checked ? new Date().toISOString() : null);
|
|
4930
5402
|
}}
|
|
4931
|
-
isAdmin={
|
|
5403
|
+
isAdmin={isAdmin}
|
|
4932
5404
|
showCommunicationAdminSection={false}
|
|
4933
|
-
skipConfirmationCommunications={
|
|
4934
|
-
disableAutoCommunications={
|
|
4935
|
-
onSkipConfirmationChange={
|
|
4936
|
-
onDisableCommunicationsChange={
|
|
5405
|
+
skipConfirmationCommunications={skipConfirmationCommunications}
|
|
5406
|
+
disableAutoCommunications={disableAutoCommunications}
|
|
5407
|
+
onSkipConfirmationChange={setSkipConfirmationCommunications}
|
|
5408
|
+
onDisableCommunicationsChange={setDisableAutoCommunications}
|
|
4937
5409
|
error={checkoutFormError}
|
|
4938
5410
|
loading={loading}
|
|
4939
5411
|
totalQuantity={totalQuantity}
|
|
@@ -4941,6 +5413,7 @@ export function AdminChangeBookingFlow({
|
|
|
4941
5413
|
submitLabel={changeCheckoutButtonLabel ?? deferredInvoiceSubmitLabel}
|
|
4942
5414
|
hideSubmitButton={
|
|
4943
5415
|
showCheckoutModal ||
|
|
5416
|
+
showAdminPaymentChoice ||
|
|
4944
5417
|
!hasEffectiveChangeSelection ||
|
|
4945
5418
|
isChangeQuoteBlocked
|
|
4946
5419
|
}
|
|
@@ -4949,10 +5422,16 @@ export function AdminChangeBookingFlow({
|
|
|
4949
5422
|
attributionConfirmLabel={flowUi?.partnerAttributionConfirmLabel}
|
|
4950
5423
|
attributionConfirmed={partnerAttributionConfirmed}
|
|
4951
5424
|
onAttributionConfirmedChange={setPartnerAttributionConfirmed}
|
|
4952
|
-
lineAmountInputs={undefined}
|
|
4953
|
-
onLineAmountInputChange={
|
|
4954
|
-
|
|
4955
|
-
|
|
5425
|
+
lineAmountInputs={showProviderPricingInlineEditor ? providerPricingUi?.lineAmountInputs : undefined}
|
|
5426
|
+
onLineAmountInputChange={
|
|
5427
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputChange : undefined
|
|
5428
|
+
}
|
|
5429
|
+
onLineAmountInputBlur={
|
|
5430
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountInputBlur : undefined
|
|
5431
|
+
}
|
|
5432
|
+
onLineAmountReset={
|
|
5433
|
+
showProviderPricingInlineEditor ? providerPricingUi?.onLineAmountReset : undefined
|
|
5434
|
+
}
|
|
4956
5435
|
/>
|
|
4957
5436
|
</>
|
|
4958
5437
|
)}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { createPortal } from 'react-dom';
|
|
4
3
|
import { formatCurrencyAmount } from '../../lib/currency';
|
|
5
4
|
import type { Currency } from './CurrencySwitcher';
|
|
6
5
|
|
|
@@ -17,8 +16,8 @@ interface AdminPaymentChoiceModalProps {
|
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Provider / staff: pay now vs confirm without payment.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Render inline (not portaled) so it remains interactive when embedded inside modal hosts
|
|
20
|
+
* that lock pointer events/inert siblings on `document.body`.
|
|
22
21
|
*/
|
|
23
22
|
export function AdminPaymentChoiceModal({
|
|
24
23
|
open,
|
|
@@ -34,10 +33,15 @@ export function AdminPaymentChoiceModal({
|
|
|
34
33
|
|
|
35
34
|
const modal = (
|
|
36
35
|
<div
|
|
37
|
-
className="booking-flow-root booking-flow-preflight fixed inset-0 z-[10050] flex items-center justify-center p-4 bg-black/50"
|
|
36
|
+
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"
|
|
38
37
|
style={{ zIndex: 100_000 }}
|
|
38
|
+
role="dialog"
|
|
39
|
+
aria-modal="true"
|
|
39
40
|
>
|
|
40
|
-
<div
|
|
41
|
+
<div
|
|
42
|
+
className="bg-white rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col pointer-events-auto"
|
|
43
|
+
onClick={(e) => e.stopPropagation()}
|
|
44
|
+
>
|
|
41
45
|
<div className="p-6 border-b border-stone-200 flex-shrink-0">
|
|
42
46
|
<div className="flex justify-between items-start gap-3">
|
|
43
47
|
<h3 className="text-lg font-semibold text-stone-900 pr-2">
|
|
@@ -94,5 +98,5 @@ export function AdminPaymentChoiceModal({
|
|
|
94
98
|
</div>
|
|
95
99
|
</div>
|
|
96
100
|
);
|
|
97
|
-
return
|
|
101
|
+
return modal;
|
|
98
102
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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.
|