@ticketboothapp/booking 1.2.165 → 1.2.167
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 +1 -1
- package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
- package/src/components/booking/AdminChangeBookingFlow.tsx +97 -32
- package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
- package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
- package/src/components/booking/AdminChangeReceiptComparison.tsx +9 -2
- package/src/components/booking/NewBookingFlow.tsx +14 -2
- package/src/components/booking/PriceBreakdown.tsx +66 -15
- package/src/components/booking/PriceSummary.tsx +12 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +25 -1
- package/src/components/booking/admin-change-payment-choice-runner.ts +12 -0
- package/src/components/booking/admin-change-provider-payload.ts +9 -1
- package/src/components/booking/admin-change-quote-request-key.ts +6 -6
- package/src/components/booking/admin-change-receipt-lines.ts +49 -0
- package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
- package/src/components/booking/admin-refund-decision-context.ts +85 -0
- package/src/components/booking/availability-date-selection.ts +16 -0
- package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
- package/src/components/booking/use-standard-booking-availability.ts +5 -2
- package/src/components/booking/useAdminChangeCheckoutController.ts +90 -11
- package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
- package/src/components/booking/useAdminChangeQuotePreview.ts +47 -37
- package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
- package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
- package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
- package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
- package/src/lib/booking/booking-cutoffs.ts +22 -0
- package/src/lib/booking/change-booking-server-preview.ts +75 -5
- package/src/lib/booking-api.ts +12 -1
- package/test/change-booking-helpers.test.ts +466 -3
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
4
4
|
import {
|
|
5
5
|
type AdminAmendmentRefundDisposition,
|
|
6
|
+
type AdminPromoApplicationScope,
|
|
6
7
|
type Availability,
|
|
7
8
|
type ReturnOption,
|
|
8
9
|
} from '../../lib/booking-api';
|
|
@@ -15,6 +16,7 @@ import { CURRENCIES, DEFAULT_CURRENCY, type Currency } from './CurrencySwitcher'
|
|
|
15
16
|
import { useCompanyTimezone } from '../../contexts/CompanyContext';
|
|
16
17
|
import { useBookingApp } from '../../contexts/BookingAppContext';
|
|
17
18
|
import { useBookingHost } from '../../runtime';
|
|
19
|
+
import { filterAvailabilitiesAfterCurrentTime } from '../../lib/booking/booking-cutoffs';
|
|
18
20
|
import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
19
21
|
import {
|
|
20
22
|
normalizeAddOnSelections,
|
|
@@ -55,15 +57,14 @@ import { useSeedPromoCodeInput } from './useSeedPromoCodeInput';
|
|
|
55
57
|
import { useAdminChangeProductReset } from './useAdminChangeProductReset';
|
|
56
58
|
import { useBookingQuantityCapTrim } from './useBookingQuantityCapTrim';
|
|
57
59
|
import { useBookingViewItemAnalytics } from './useBookingViewItemAnalytics';
|
|
60
|
+
import { AdminChangePromoEditor } from './AdminChangePromoEditor';
|
|
58
61
|
import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
|
|
59
62
|
import type { AdminReleaseRefundDisposition } from './admin-refund-disposition';
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
removedValueByOperationId: NonNullable<AdminChangeLatestQuote['removedValueByOperationId']>;
|
|
66
|
-
};
|
|
63
|
+
import {
|
|
64
|
+
mergeAdminRefundDecisionContext,
|
|
65
|
+
selectRefundDispositionsForOperations,
|
|
66
|
+
type AdminRefundDecisionContext,
|
|
67
|
+
} from './admin-refund-decision-context';
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* ## Pricing contract (customer self-serve)
|
|
@@ -148,6 +149,24 @@ export function AdminChangeBookingFlow({
|
|
|
148
149
|
isAdmin,
|
|
149
150
|
companyId: env.COMPANY_ID,
|
|
150
151
|
});
|
|
152
|
+
const [adminAvailabilityNowMs, setAdminAvailabilityNowMs] = useState(() => Date.now());
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const interval = window.setInterval(() => setAdminAvailabilityNowMs(Date.now()), 60 * 1000);
|
|
156
|
+
return () => window.clearInterval(interval);
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const adminAvailabilityNow = useMemo(
|
|
160
|
+
() => new Date(adminAvailabilityNowMs),
|
|
161
|
+
[adminAvailabilityNowMs],
|
|
162
|
+
);
|
|
163
|
+
const filterAdminCalendarAvailabilities = useCallback(
|
|
164
|
+
(rows: Availability[]) =>
|
|
165
|
+
product.productType === 'PRIVATE_SHUTTLE'
|
|
166
|
+
? rows
|
|
167
|
+
: filterAvailabilitiesAfterCurrentTime(rows, adminAvailabilityNow),
|
|
168
|
+
[adminAvailabilityNow, product.productType],
|
|
169
|
+
);
|
|
151
170
|
const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
|
|
152
171
|
const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
|
|
153
172
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
|
@@ -156,6 +175,8 @@ export function AdminChangeBookingFlow({
|
|
|
156
175
|
const [lastName, setLastName] = useState('');
|
|
157
176
|
const [promoCodeInput, setPromoCodeInput] = useState('');
|
|
158
177
|
const [appliedPromoCode, setAppliedPromoCode] = useState<string | null>(null);
|
|
178
|
+
const [promoApplicationScope, setPromoApplicationScope] =
|
|
179
|
+
useState<AdminPromoApplicationScope>('CHANGE_ONLY');
|
|
159
180
|
const [pickupLocationId, setPickupLocationId] = useState<string | null>(null);
|
|
160
181
|
const [pickupLocationSkipped, setPickupLocationSkipped] = useState(false);
|
|
161
182
|
/** Cancellation policy is fixed to the existing booking snapshot — not user-editable in change flow. */
|
|
@@ -224,10 +245,11 @@ export function AdminChangeBookingFlow({
|
|
|
224
245
|
* user picks a different return time — baseline is the first auto-selected return for this outbound.
|
|
225
246
|
*/
|
|
226
247
|
const [implicitReturnBaselineId, setImplicitReturnBaselineId] = useState<string | null>(null);
|
|
227
|
-
|
|
228
|
-
const lockedPromoCode = initialValues?.promoCode?.trim()
|
|
248
|
+
const historicalPromoCode = initialValues?.promoCode?.trim()
|
|
229
249
|
? initialValues.promoCode.trim().toUpperCase()
|
|
230
250
|
: null;
|
|
251
|
+
/** Public/self-serve keeps its historical promo locked. Admin amendments require new explicit intent. */
|
|
252
|
+
const lockedPromoCode = isProviderDashboardChange ? null : historicalPromoCode;
|
|
231
253
|
/** Public self-serve only: cannot reduce tickets below original counts. Provider-dashboard admins may reduce party size. */
|
|
232
254
|
const changeBookingMinimumQuantities = useMemo(
|
|
233
255
|
() =>
|
|
@@ -283,6 +305,7 @@ export function AdminChangeBookingFlow({
|
|
|
283
305
|
appliedPromoCode,
|
|
284
306
|
pricingProfileIdForAvailabilities,
|
|
285
307
|
cancellationPolicyProfileIdForAvailabilities,
|
|
308
|
+
filterCalendarAvailabilities: filterAdminCalendarAvailabilities,
|
|
286
309
|
resetKey: selectedChangeProductId,
|
|
287
310
|
setQuantities,
|
|
288
311
|
setError,
|
|
@@ -692,6 +715,8 @@ export function AdminChangeBookingFlow({
|
|
|
692
715
|
quantities,
|
|
693
716
|
addOnSelections,
|
|
694
717
|
cancellationPolicyId,
|
|
718
|
+
promoCode: appliedPromoCode,
|
|
719
|
+
promoApplicationScope,
|
|
695
720
|
adminCustomReceiptLines,
|
|
696
721
|
structuredAdjustments: adminStructuredAdjustments,
|
|
697
722
|
useAdminFeAuthoritativeQuote,
|
|
@@ -705,6 +730,8 @@ export function AdminChangeBookingFlow({
|
|
|
705
730
|
quantities,
|
|
706
731
|
addOnSelections,
|
|
707
732
|
cancellationPolicyId,
|
|
733
|
+
appliedPromoCode,
|
|
734
|
+
promoApplicationScope,
|
|
708
735
|
adminCustomReceiptLines,
|
|
709
736
|
adminStructuredAdjustments,
|
|
710
737
|
useAdminFeAuthoritativeQuote,
|
|
@@ -714,41 +741,41 @@ export function AdminChangeBookingFlow({
|
|
|
714
741
|
latestChangeQuote?.returnPriceTreatmentOperations ?? [];
|
|
715
742
|
useEffect(() => {
|
|
716
743
|
if (responseRefundDecisionOperations.length === 0) return;
|
|
717
|
-
setRefundDecisionContext({
|
|
744
|
+
setRefundDecisionContext((current) => mergeAdminRefundDecisionContext({
|
|
745
|
+
current,
|
|
718
746
|
selectionKey: refundDecisionSelectionKey,
|
|
719
747
|
operations: responseRefundDecisionOperations,
|
|
720
748
|
allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
|
|
721
749
|
removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
|
|
722
|
-
});
|
|
750
|
+
}));
|
|
723
751
|
}, [
|
|
724
752
|
refundDecisionSelectionKey,
|
|
725
753
|
responseRefundDecisionOperations,
|
|
726
754
|
latestChangeQuote?.allowedRefundDispositionsByOperationId,
|
|
727
755
|
latestChangeQuote?.removedValueByOperationId,
|
|
728
756
|
]);
|
|
729
|
-
const activeRefundDecisionContext =
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
757
|
+
const activeRefundDecisionContext = useMemo(() => mergeAdminRefundDecisionContext({
|
|
758
|
+
current: refundDecisionContext,
|
|
759
|
+
selectionKey: refundDecisionSelectionKey,
|
|
760
|
+
operations: responseRefundDecisionOperations,
|
|
761
|
+
allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
|
|
762
|
+
removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
|
|
763
|
+
}), [
|
|
764
|
+
refundDecisionContext,
|
|
765
|
+
refundDecisionSelectionKey,
|
|
766
|
+
responseRefundDecisionOperations,
|
|
767
|
+
latestChangeQuote?.allowedRefundDispositionsByOperationId,
|
|
768
|
+
latestChangeQuote?.removedValueByOperationId,
|
|
769
|
+
]);
|
|
739
770
|
const activeReturnPriceTreatmentOperations =
|
|
740
771
|
responseReturnPriceTreatmentOperations.length > 0
|
|
741
772
|
? responseReturnPriceTreatmentOperations
|
|
742
773
|
: [];
|
|
743
774
|
const activeRefundDispositionsByOperationId = useMemo(() => {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
return Object.fromEntries(
|
|
749
|
-
Object.entries(refundDispositionsByOperationId).filter(([operationId]) =>
|
|
750
|
-
relevantOperationIds.has(operationId),
|
|
751
|
-
),
|
|
775
|
+
return selectRefundDispositionsForOperations(
|
|
776
|
+
activeRefundDecisionContext,
|
|
777
|
+
activeReturnPriceTreatmentOperations,
|
|
778
|
+
refundDispositionsByOperationId,
|
|
752
779
|
);
|
|
753
780
|
}, [
|
|
754
781
|
activeRefundDecisionContext?.operations,
|
|
@@ -787,6 +814,8 @@ export function AdminChangeBookingFlow({
|
|
|
787
814
|
quantities,
|
|
788
815
|
addOnSelections,
|
|
789
816
|
cancellationPolicyId,
|
|
817
|
+
appliedPromoCode,
|
|
818
|
+
promoApplicationScope,
|
|
790
819
|
adminCustomReceiptLines,
|
|
791
820
|
adminStructuredAdjustments,
|
|
792
821
|
refundDispositionsByOperationId: activeRefundDispositionsByOperationId,
|
|
@@ -899,7 +928,13 @@ export function AdminChangeBookingFlow({
|
|
|
899
928
|
setPickupLocationSkipped,
|
|
900
929
|
});
|
|
901
930
|
|
|
902
|
-
const {
|
|
931
|
+
const {
|
|
932
|
+
handleQuantityChange,
|
|
933
|
+
handleApplyPromo,
|
|
934
|
+
handleRemovePromo,
|
|
935
|
+
promoCodeError,
|
|
936
|
+
promoCodeValidating,
|
|
937
|
+
} = useBookingPromoAndQuantityController({
|
|
903
938
|
isAdmin,
|
|
904
939
|
effectivePartySizeCap,
|
|
905
940
|
currentTotalQuantity: orderSummary.totalQuantity,
|
|
@@ -918,13 +953,40 @@ export function AdminChangeBookingFlow({
|
|
|
918
953
|
setError,
|
|
919
954
|
});
|
|
920
955
|
|
|
956
|
+
const adminPromoEditor = (
|
|
957
|
+
<AdminChangePromoEditor
|
|
958
|
+
previousPromoCode={historicalPromoCode}
|
|
959
|
+
promoCodeInput={promoCodeInput}
|
|
960
|
+
appliedPromoCode={appliedPromoCode}
|
|
961
|
+
promoCodeError={promoCodeError}
|
|
962
|
+
promoCodeValidating={promoCodeValidating}
|
|
963
|
+
scope={promoApplicationScope}
|
|
964
|
+
currency={currency}
|
|
965
|
+
locale={locale}
|
|
966
|
+
t={t}
|
|
967
|
+
onInputChange={(value) => {
|
|
968
|
+
setPromoCodeInput(value);
|
|
969
|
+
if (appliedPromoCode && value.trim().toUpperCase() !== appliedPromoCode) {
|
|
970
|
+
setAppliedPromoCode(null);
|
|
971
|
+
}
|
|
972
|
+
}}
|
|
973
|
+
onApply={() => { void handleApplyPromo(); }}
|
|
974
|
+
onRemove={() => {
|
|
975
|
+
setPromoCodeInput('');
|
|
976
|
+
setPromoApplicationScope('CHANGE_ONLY');
|
|
977
|
+
handleRemovePromo();
|
|
978
|
+
}}
|
|
979
|
+
onScopeChange={setPromoApplicationScope}
|
|
980
|
+
/>
|
|
981
|
+
);
|
|
982
|
+
|
|
921
983
|
const {
|
|
922
984
|
showCheckoutModal,
|
|
923
985
|
checkoutClientSecret,
|
|
924
986
|
checkoutModalData,
|
|
925
987
|
showAdminPaymentChoice,
|
|
926
988
|
adminChoiceData,
|
|
927
|
-
|
|
989
|
+
handleCheckoutClose,
|
|
928
990
|
handleCheckout,
|
|
929
991
|
handleConfirmWithoutPayment,
|
|
930
992
|
handlePayNow,
|
|
@@ -969,6 +1031,7 @@ export function AdminChangeBookingFlow({
|
|
|
969
1031
|
onChangeBooking,
|
|
970
1032
|
cancellationPolicyId,
|
|
971
1033
|
appliedPromoCode,
|
|
1034
|
+
promoApplicationScope,
|
|
972
1035
|
displayChangeFlowProposedTotalWithEditableLines,
|
|
973
1036
|
providerPricingOverrides,
|
|
974
1037
|
mergedProviderAdditionalAdjustments,
|
|
@@ -1022,7 +1085,7 @@ export function AdminChangeBookingFlow({
|
|
|
1022
1085
|
...{ pickupLocationId, pickupLocationSkipped, firstName, lastName, email, getSuccessUrl, t },
|
|
1023
1086
|
onPayNow: handlePayNow,
|
|
1024
1087
|
onConfirmWithoutPayment: handleConfirmWithoutPayment, onAdminPaymentChoiceCancel: handleAdminPaymentChoiceCancel,
|
|
1025
|
-
onCheckoutClose:
|
|
1088
|
+
onCheckoutClose: handleCheckoutClose, onPaymentSubmitStart: handlePaymentSubmitStart,
|
|
1026
1089
|
onPaymentSubmitError: handlePaymentSubmitError, onPaymentConfirmed: handlePaymentConfirmed,
|
|
1027
1090
|
isPartialLaunch,
|
|
1028
1091
|
checkoutVisible: selectedAvailability != null,
|
|
@@ -1099,6 +1162,8 @@ export function AdminChangeBookingFlow({
|
|
|
1099
1162
|
taxRate: pricingConfig?.taxRate,
|
|
1100
1163
|
...{ providerPricingUi, providerQuotedLines },
|
|
1101
1164
|
debugPanel: changeFlowAdminPricingDebugPanel,
|
|
1165
|
+
promoEditor: adminPromoEditor,
|
|
1166
|
+
promoCode: appliedPromoCode,
|
|
1102
1167
|
...{ showChangeFlowManualPriceLines, showAdminCustomLineEditor, adminCustomReceiptLines },
|
|
1103
1168
|
onAddAdminCustomReceiptLine: handleAddAdminCustomReceiptLine,
|
|
1104
1169
|
onUpdateAdminCustomReceiptLine: handleUpdateAdminCustomReceiptLine,
|
|
@@ -59,6 +59,8 @@ export interface AdminChangeCheckoutPanelProps {
|
|
|
59
59
|
providerPricingUi?: ProviderDashboardChangePricingUi;
|
|
60
60
|
providerQuotedLines: ProviderDashboardPricingLine[];
|
|
61
61
|
debugPanel: ReactNode;
|
|
62
|
+
promoEditor?: ReactNode;
|
|
63
|
+
promoCode?: string | null;
|
|
62
64
|
showChangeFlowManualPriceLines: boolean;
|
|
63
65
|
showAdminCustomLineEditor: boolean;
|
|
64
66
|
adminCustomReceiptLines: AdminCustomReceiptLine[];
|
|
@@ -139,6 +141,8 @@ export function AdminChangeCheckoutPanel({
|
|
|
139
141
|
providerPricingUi,
|
|
140
142
|
providerQuotedLines,
|
|
141
143
|
debugPanel,
|
|
144
|
+
promoEditor,
|
|
145
|
+
promoCode,
|
|
142
146
|
showChangeFlowManualPriceLines,
|
|
143
147
|
showAdminCustomLineEditor,
|
|
144
148
|
adminCustomReceiptLines,
|
|
@@ -229,6 +233,8 @@ export function AdminChangeCheckoutPanel({
|
|
|
229
233
|
locale={locale}
|
|
230
234
|
t={t}
|
|
231
235
|
taxRate={taxRate}
|
|
236
|
+
promoEditor={promoEditor}
|
|
237
|
+
promoCode={promoCode}
|
|
232
238
|
adjustments={pricingAdjustments}
|
|
233
239
|
/>
|
|
234
240
|
) : null;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AdminPromoApplicationScope } from '../../lib/booking-api';
|
|
2
|
+
import type { Currency } from './CurrencySwitcher';
|
|
3
|
+
import { PromoCodeInput } from './PromoCodeInput';
|
|
4
|
+
|
|
5
|
+
type TranslationFn = (key: string, params?: Record<string, string>) => string;
|
|
6
|
+
|
|
7
|
+
interface AdminChangePromoEditorProps {
|
|
8
|
+
previousPromoCode: string | null;
|
|
9
|
+
promoCodeInput: string;
|
|
10
|
+
appliedPromoCode: string | null;
|
|
11
|
+
promoCodeError: string;
|
|
12
|
+
promoCodeValidating: boolean;
|
|
13
|
+
scope: AdminPromoApplicationScope;
|
|
14
|
+
currency: Currency;
|
|
15
|
+
locale: string;
|
|
16
|
+
t: TranslationFn;
|
|
17
|
+
onInputChange: (value: string) => void;
|
|
18
|
+
onApply: () => void;
|
|
19
|
+
onRemove: () => void;
|
|
20
|
+
onScopeChange: (scope: AdminPromoApplicationScope) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function AdminChangePromoEditor({
|
|
24
|
+
previousPromoCode,
|
|
25
|
+
promoCodeInput,
|
|
26
|
+
appliedPromoCode,
|
|
27
|
+
promoCodeError,
|
|
28
|
+
promoCodeValidating,
|
|
29
|
+
scope,
|
|
30
|
+
currency,
|
|
31
|
+
locale,
|
|
32
|
+
t,
|
|
33
|
+
onInputChange,
|
|
34
|
+
onApply,
|
|
35
|
+
onRemove,
|
|
36
|
+
onScopeChange,
|
|
37
|
+
}: AdminChangePromoEditorProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="mb-3 rounded-lg border border-sky-200 bg-white/80 p-3">
|
|
40
|
+
<PromoCodeInput
|
|
41
|
+
promoCodeInput={promoCodeInput}
|
|
42
|
+
appliedPromoCode={appliedPromoCode}
|
|
43
|
+
promoCodeError={promoCodeError}
|
|
44
|
+
promoCodeValidating={promoCodeValidating}
|
|
45
|
+
promoDiscountAmount={0}
|
|
46
|
+
currency={currency}
|
|
47
|
+
locale={locale}
|
|
48
|
+
t={t}
|
|
49
|
+
onInputChange={onInputChange}
|
|
50
|
+
onApply={onApply}
|
|
51
|
+
onRemove={onRemove}
|
|
52
|
+
hideDiscountAmount
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
{previousPromoCode ? (
|
|
56
|
+
<p className="mt-2 text-xs text-stone-600">
|
|
57
|
+
Earlier value keeps its existing <span className="font-semibold">{previousPromoCode}</span> allocation.
|
|
58
|
+
It is not automatically reused on this amendment.
|
|
59
|
+
</p>
|
|
60
|
+
) : null}
|
|
61
|
+
|
|
62
|
+
{appliedPromoCode ? (
|
|
63
|
+
<fieldset className="mt-3 space-y-2">
|
|
64
|
+
<legend className="text-xs font-semibold text-stone-800">Apply this code to</legend>
|
|
65
|
+
<label className="flex cursor-pointer items-start gap-2 text-xs text-stone-700">
|
|
66
|
+
<input
|
|
67
|
+
type="radio"
|
|
68
|
+
name="adminPromoApplicationScope"
|
|
69
|
+
value="CHANGE_ONLY"
|
|
70
|
+
checked={scope === 'CHANGE_ONLY'}
|
|
71
|
+
onChange={() => onScopeChange('CHANGE_ONLY')}
|
|
72
|
+
/>
|
|
73
|
+
<span>
|
|
74
|
+
<span className="font-semibold">This change only</span>
|
|
75
|
+
<span className="block text-stone-500">Discount only eligible positive value introduced now.</span>
|
|
76
|
+
</span>
|
|
77
|
+
</label>
|
|
78
|
+
<label className="flex cursor-pointer items-start gap-2 text-xs text-stone-700">
|
|
79
|
+
<input
|
|
80
|
+
type="radio"
|
|
81
|
+
name="adminPromoApplicationScope"
|
|
82
|
+
value="ENTIRE_BOOKING"
|
|
83
|
+
checked={scope === 'ENTIRE_BOOKING'}
|
|
84
|
+
onChange={() => onScopeChange('ENTIRE_BOOKING')}
|
|
85
|
+
/>
|
|
86
|
+
<span>
|
|
87
|
+
<span className="font-semibold">Entire active booking</span>
|
|
88
|
+
<span className="block text-stone-500">
|
|
89
|
+
Top up currently eligible value without duplicating discounts already allocated by earlier ledger changes.
|
|
90
|
+
</span>
|
|
91
|
+
</span>
|
|
92
|
+
</label>
|
|
93
|
+
</fieldset>
|
|
94
|
+
) : null}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { Locale } from '../../lib/booking/i18n/config';
|
|
|
9
9
|
import type { ChangeBookingFlowProps } from './booking-flow-types';
|
|
10
10
|
import type { Currency } from './CurrencySwitcher';
|
|
11
11
|
import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
|
|
12
|
+
import { collapseAdminPromoLines } from './admin-change-receipt-lines';
|
|
12
13
|
import {
|
|
13
14
|
adminRemovalOperationLabel,
|
|
14
15
|
noRefundRemovedValue,
|
|
@@ -39,6 +40,8 @@ export interface AdminChangeReceiptComparisonProps {
|
|
|
39
40
|
t: TranslationFn;
|
|
40
41
|
taxRate?: number;
|
|
41
42
|
adjustments?: ReactNode;
|
|
43
|
+
promoEditor?: ReactNode;
|
|
44
|
+
promoCode?: string | null;
|
|
42
45
|
refundDecisionOperations?: AdminAmendmentOperationSnapshot[];
|
|
43
46
|
returnPriceTreatmentOperations?: AdminAmendmentOperationSnapshot[];
|
|
44
47
|
allowedRefundDispositionsByOperationId?: Record<string, AdminAmendmentRefundDisposition[]>;
|
|
@@ -89,6 +92,8 @@ export function AdminChangeReceiptComparison({
|
|
|
89
92
|
t,
|
|
90
93
|
taxRate,
|
|
91
94
|
adjustments,
|
|
95
|
+
promoEditor,
|
|
96
|
+
promoCode,
|
|
92
97
|
refundDecisionOperations = [],
|
|
93
98
|
returnPriceTreatmentOperations = [],
|
|
94
99
|
allowedRefundDispositionsByOperationId = {},
|
|
@@ -135,13 +140,14 @@ export function AdminChangeReceiptComparison({
|
|
|
135
140
|
.sort((a, b) => amendmentLineOrder(a.line) - amendmentLineOrder(b.line) || a.index - b.index)
|
|
136
141
|
.map(({ line }) => line)
|
|
137
142
|
: [];
|
|
138
|
-
const
|
|
143
|
+
const collapsedAmendmentNonTaxLines = collapseAdminPromoLines(amendmentNonTaxLines, promoCode);
|
|
144
|
+
const amendmentSubtotal = collapsedAmendmentNonTaxLines.reduce(
|
|
139
145
|
(sum, line) => sum + (line.kind === 'ticket' ? line.itemTotal : line.amount),
|
|
140
146
|
0,
|
|
141
147
|
);
|
|
142
148
|
const amendmentDisplayLines: PriceSummaryLine[] = amendmentLines
|
|
143
149
|
? [
|
|
144
|
-
...
|
|
150
|
+
...collapsedAmendmentNonTaxLines,
|
|
145
151
|
...(Math.abs(amendmentTax) >= 0.005
|
|
146
152
|
? [{
|
|
147
153
|
kind: 'line' as const,
|
|
@@ -293,6 +299,7 @@ export function AdminChangeReceiptComparison({
|
|
|
293
299
|
{displayedAmountDue == null ? '—' : formatCurrencyAmount(displayedAmountDue, currency, displayLocale)}
|
|
294
300
|
</span>
|
|
295
301
|
</div>
|
|
302
|
+
{promoEditor}
|
|
296
303
|
{!selectionChanged ? (
|
|
297
304
|
<div className="rounded-md border border-dashed border-stone-300 bg-white/70 px-3 py-4 text-center">
|
|
298
305
|
<p className="text-sm font-medium text-stone-700">No changes selected</p>
|
|
@@ -149,10 +149,9 @@ export function NewBookingFlow({
|
|
|
149
149
|
}, [activeOptions]);
|
|
150
150
|
|
|
151
151
|
useEffect(() => {
|
|
152
|
-
if (isAdmin) return;
|
|
153
152
|
const interval = window.setInterval(() => setBookingCutoffNowMs(Date.now()), 60 * 1000);
|
|
154
153
|
return () => window.clearInterval(interval);
|
|
155
|
-
}, [
|
|
154
|
+
}, []);
|
|
156
155
|
|
|
157
156
|
const bookingCutoffNow = useMemo(() => new Date(bookingCutoffNowMs), [bookingCutoffNowMs]);
|
|
158
157
|
const bookingCutoffMinutes = product.bookingCutoffMinutes ?? null;
|
|
@@ -213,6 +212,19 @@ export function NewBookingFlow({
|
|
|
213
212
|
onProductOptionChange: clearAddOnSelections,
|
|
214
213
|
});
|
|
215
214
|
|
|
215
|
+
const previousAddOnIdsRef = useRef<Set<string>>(new Set());
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const availableIds = new Set(addOns.map((a) => a.addOnId));
|
|
218
|
+
const previousIds = previousAddOnIdsRef.current;
|
|
219
|
+
if (previousIds.size > 0) {
|
|
220
|
+
setAddOnSelections((prev) => {
|
|
221
|
+
const next = prev.filter((s) => !previousIds.has(s.addOnId) || availableIds.has(s.addOnId));
|
|
222
|
+
return next.length === prev.length ? prev : next;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
previousAddOnIdsRef.current = availableIds;
|
|
226
|
+
}, [addOns]);
|
|
227
|
+
|
|
216
228
|
const hidePickupSkipOption = useMemo(
|
|
217
229
|
() =>
|
|
218
230
|
!isAdmin &&
|
|
@@ -13,6 +13,14 @@ export interface PriceBreakdownProps {
|
|
|
13
13
|
qty: number;
|
|
14
14
|
itemTotal: number;
|
|
15
15
|
breakdown: PriceBreakdownType | null;
|
|
16
|
+
unitPriceComparison?: {
|
|
17
|
+
previousLabel: string;
|
|
18
|
+
updatedLabel: string;
|
|
19
|
+
differenceLabel: string;
|
|
20
|
+
previousUnitAmount: number;
|
|
21
|
+
updatedUnitAmount: number;
|
|
22
|
+
differenceUnitAmount: number;
|
|
23
|
+
};
|
|
16
24
|
currency: Currency;
|
|
17
25
|
locale: Locale;
|
|
18
26
|
editable?: boolean;
|
|
@@ -60,6 +68,7 @@ export function PriceBreakdown({
|
|
|
60
68
|
qty,
|
|
61
69
|
itemTotal,
|
|
62
70
|
breakdown,
|
|
71
|
+
unitPriceComparison,
|
|
63
72
|
currency,
|
|
64
73
|
locale,
|
|
65
74
|
editable = false,
|
|
@@ -214,17 +223,22 @@ export function PriceBreakdown({
|
|
|
214
223
|
Price Breakdown ({category})
|
|
215
224
|
</div>
|
|
216
225
|
<div className="space-y-1.5">
|
|
217
|
-
{breakdown.lineItems.
|
|
218
|
-
<div
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
{unitPriceComparison && breakdown.lineItems.length > 0 ? (
|
|
227
|
+
<div className="font-medium text-stone-300">New-date price calculation</div>
|
|
228
|
+
) : null}
|
|
229
|
+
{(!unitPriceComparison || breakdown.lineItems.length > 0) ? (
|
|
230
|
+
breakdown.lineItems.map((line) => (
|
|
231
|
+
<div key={line.id ?? line.name} className="flex justify-between">
|
|
232
|
+
<span className="text-stone-400">
|
|
233
|
+
{line.name}{line.type === 'base' ? ` (${currency})` : ''}{formatAdjustmentDetail(line.adjustmentType, line.adjustmentValue, line.amountInDisplayCurrency < 0)}
|
|
234
|
+
</span>
|
|
235
|
+
<span className={line.type === 'adjustment' && line.amountInDisplayCurrency < 0 ? 'text-red-300' : ''}>
|
|
236
|
+
{line.type === 'adjustment' && line.amountInDisplayCurrency >= 0 ? '+' : ''}
|
|
237
|
+
{formatCurrencyAmount(line.amountInDisplayCurrency, currency, locale)}
|
|
238
|
+
</span>
|
|
239
|
+
</div>
|
|
240
|
+
))
|
|
241
|
+
) : null}
|
|
228
242
|
{/* Only show fees and tax in breakdown when rolled up (tax-inclusive); for CAD/USD they're already separate line items. */}
|
|
229
243
|
{breakdown.isTaxIncluded && (
|
|
230
244
|
<>
|
|
@@ -244,10 +258,47 @@ export function PriceBreakdown({
|
|
|
244
258
|
</div>
|
|
245
259
|
</>
|
|
246
260
|
)}
|
|
247
|
-
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
261
|
+
{unitPriceComparison && breakdown.lineItems.length > 0 ? (
|
|
262
|
+
<div className="flex justify-between pt-2 mt-2 border-t border-stone-700 font-semibold">
|
|
263
|
+
<span>New-date final price:</span>
|
|
264
|
+
<span>{formatCurrencyAmount(breakdown.finalPrice, currency, locale)}</span>
|
|
265
|
+
</div>
|
|
266
|
+
) : null}
|
|
267
|
+
{unitPriceComparison ? (
|
|
268
|
+
<>
|
|
269
|
+
{breakdown.lineItems.length > 0 ? (
|
|
270
|
+
<div className="pt-2 mt-2 border-t border-stone-700 font-medium text-stone-300">
|
|
271
|
+
Price difference
|
|
272
|
+
</div>
|
|
273
|
+
) : null}
|
|
274
|
+
<div className="flex justify-between gap-3">
|
|
275
|
+
<span className="text-stone-400">{unitPriceComparison.previousLabel}</span>
|
|
276
|
+
<span>{formatCurrencyAmount(unitPriceComparison.previousUnitAmount, currency, locale)}</span>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="flex justify-between gap-3">
|
|
279
|
+
<span className="text-stone-400">{unitPriceComparison.updatedLabel}</span>
|
|
280
|
+
<span>{formatCurrencyAmount(unitPriceComparison.updatedUnitAmount, currency, locale)}</span>
|
|
281
|
+
</div>
|
|
282
|
+
<div className="flex justify-between gap-3 border-t border-stone-700 pt-1.5">
|
|
283
|
+
<span className="text-stone-400">{unitPriceComparison.differenceLabel}</span>
|
|
284
|
+
<span className={unitPriceComparison.differenceUnitAmount < 0 ? 'text-red-300' : ''}>
|
|
285
|
+
{unitPriceComparison.differenceUnitAmount > 0 ? '+' : ''}
|
|
286
|
+
{formatCurrencyAmount(unitPriceComparison.differenceUnitAmount, currency, locale)}
|
|
287
|
+
</span>
|
|
288
|
+
</div>
|
|
289
|
+
</>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="flex justify-between pt-2 mt-2 border-t border-stone-700 font-semibold">
|
|
292
|
+
<span>Final price ({currency}):</span>
|
|
293
|
+
<span>{formatCurrencyAmount(breakdown.finalPrice, currency, locale)}</span>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
{unitPriceComparison && qty > 1 ? (
|
|
297
|
+
<div className="flex justify-between pt-2 mt-2 border-t border-stone-700 font-semibold">
|
|
298
|
+
<span>Total difference (× {qty}):</span>
|
|
299
|
+
<span>{formatCurrencyAmount(itemTotal, currency, locale)}</span>
|
|
300
|
+
</div>
|
|
301
|
+
) : null}
|
|
251
302
|
</div>
|
|
252
303
|
</div>
|
|
253
304
|
)}
|
|
@@ -14,11 +14,22 @@ export type PriceSummaryLine =
|
|
|
14
14
|
| {
|
|
15
15
|
kind: 'ticket';
|
|
16
16
|
lineKey?: string;
|
|
17
|
+
/** Pricing V2 amendment operation that produced this row, when applicable. */
|
|
18
|
+
operationId?: string;
|
|
17
19
|
editable?: boolean;
|
|
18
20
|
category: string;
|
|
19
21
|
qty: number;
|
|
20
22
|
itemTotal: number;
|
|
21
23
|
breakdown?: PriceBreakdownType | null;
|
|
24
|
+
/** Optional server-value comparison for amendment delta rows. */
|
|
25
|
+
unitPriceComparison?: {
|
|
26
|
+
previousLabel: string;
|
|
27
|
+
updatedLabel: string;
|
|
28
|
+
differenceLabel: string;
|
|
29
|
+
previousUnitAmount: number;
|
|
30
|
+
updatedUnitAmount: number;
|
|
31
|
+
differenceUnitAmount: number;
|
|
32
|
+
};
|
|
22
33
|
}
|
|
23
34
|
| {
|
|
24
35
|
kind: 'line';
|
|
@@ -211,6 +222,7 @@ export function PriceSummary({
|
|
|
211
222
|
qty={row.qty}
|
|
212
223
|
itemTotal={row.itemTotal}
|
|
213
224
|
breakdown={row.breakdown ?? null}
|
|
225
|
+
unitPriceComparison={row.unitPriceComparison}
|
|
214
226
|
currency={currency}
|
|
215
227
|
locale={locale}
|
|
216
228
|
editable={Boolean(row.editable && isBeforeSubtotalBoundary)}
|