@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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +97 -32
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +6 -0
  5. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +9 -2
  7. package/src/components/booking/NewBookingFlow.tsx +14 -2
  8. package/src/components/booking/PriceBreakdown.tsx +66 -15
  9. package/src/components/booking/PriceSummary.tsx +12 -0
  10. package/src/components/booking/PrivateShuttleBookingFlow.tsx +25 -1
  11. package/src/components/booking/admin-change-payment-choice-runner.ts +12 -0
  12. package/src/components/booking/admin-change-provider-payload.ts +9 -1
  13. package/src/components/booking/admin-change-quote-request-key.ts +6 -6
  14. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  15. package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
  16. package/src/components/booking/admin-refund-decision-context.ts +85 -0
  17. package/src/components/booking/availability-date-selection.ts +16 -0
  18. package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
  19. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  20. package/src/components/booking/useAdminChangeCheckoutController.ts +90 -11
  21. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
  22. package/src/components/booking/useAdminChangeQuotePreview.ts +47 -37
  23. package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
  24. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  25. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  26. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  27. package/src/lib/booking/booking-cutoffs.ts +22 -0
  28. package/src/lib/booking/change-booking-server-preview.ts +75 -5
  29. package/src/lib/booking-api.ts +12 -1
  30. package/test/change-booking-helpers.test.ts +466 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.165",
3
+ "version": "1.2.167",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -176,6 +176,8 @@ const checkoutPanelPropKeys = [
176
176
  'providerPricingUi',
177
177
  'providerQuotedLines',
178
178
  'debugPanel',
179
+ 'promoEditor',
180
+ 'promoCode',
179
181
  'showChangeFlowManualPriceLines',
180
182
  'showAdminCustomLineEditor',
181
183
  'adminCustomReceiptLines',
@@ -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
- type AdminRefundDecisionContext = {
62
- selectionKey: string;
63
- operations: NonNullable<AdminChangeLatestQuote['refundDecisionOperations']>;
64
- allowedByOperationId: NonNullable<AdminChangeLatestQuote['allowedRefundDispositionsByOperationId']>;
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
- /** Any change flow (self-serve or provider): promo from booking is fixed — show read-only, never add new. */
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 = responseRefundDecisionOperations.length > 0
730
- ? {
731
- selectionKey: refundDecisionSelectionKey,
732
- operations: responseRefundDecisionOperations,
733
- allowedByOperationId: latestChangeQuote?.allowedRefundDispositionsByOperationId ?? {},
734
- removedValueByOperationId: latestChangeQuote?.removedValueByOperationId ?? {},
735
- }
736
- : refundDecisionContext?.selectionKey === refundDecisionSelectionKey
737
- ? refundDecisionContext
738
- : null;
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
- const relevantOperationIds = new Set([
745
- ...(activeRefundDecisionContext?.operations ?? []).map((operation) => operation.operationId),
746
- ...activeReturnPriceTreatmentOperations.map((operation) => operation.operationId),
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 { handleQuantityChange } = useBookingPromoAndQuantityController({
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
- cancelPendingReservation,
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: cancelPendingReservation, onPaymentSubmitStart: handlePaymentSubmitStart,
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 amendmentSubtotal = amendmentNonTaxLines.reduce(
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
- ...amendmentNonTaxLines,
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
- }, [isAdmin]);
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.map((line) => (
218
- <div key={line.id ?? line.name} className="flex justify-between">
219
- <span className="text-stone-400">
220
- {line.name}{line.type === 'base' ? ` (${currency})` : ''}{formatAdjustmentDetail(line.adjustmentType, line.adjustmentValue, line.amountInDisplayCurrency < 0)}
221
- </span>
222
- <span className={line.type === 'adjustment' && line.amountInDisplayCurrency < 0 ? 'text-red-300' : ''}>
223
- {line.type === 'adjustment' && line.amountInDisplayCurrency >= 0 ? '+' : ''}
224
- {formatCurrencyAmount(line.amountInDisplayCurrency, currency, locale)}
225
- </span>
226
- </div>
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
- <div className="flex justify-between pt-2 mt-2 border-t border-stone-700 font-semibold">
248
- <span>Final price ({currency}):</span>
249
- <span>{formatCurrencyAmount(breakdown.finalPrice, currency, locale)}</span>
250
- </div>
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)}