@ticketboothapp/booking 1.2.166 → 1.2.168

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 (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  7. package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
  8. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  9. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  10. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  11. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  12. package/src/components/booking/NewBookingFlow.tsx +14 -2
  13. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  14. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  15. package/src/components/booking/PriceBreakdown.tsx +66 -15
  16. package/src/components/booking/PriceSummary.tsx +16 -0
  17. package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
  18. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  19. package/src/components/booking/admin-adjustment-components.ts +44 -0
  20. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  21. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  22. package/src/components/booking/admin-change-provider-payload.ts +12 -1
  23. package/src/components/booking/admin-change-quote-request-key.ts +41 -1
  24. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  25. package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
  26. package/src/components/booking/admin-refund-decision-context.ts +122 -0
  27. package/src/components/booking/admin-refund-disposition.ts +71 -2
  28. package/src/components/booking/availability-date-selection.ts +16 -0
  29. package/src/components/booking/booking-flow-types.ts +4 -0
  30. package/src/components/booking/booking-flow.css +5 -0
  31. package/src/components/booking/change-booking-error-message.ts +137 -0
  32. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  33. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  34. package/src/components/booking/change-booking-quote-state.ts +44 -1
  35. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  36. package/src/components/booking/private-shuttle-availability.ts +14 -0
  37. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  38. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  39. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  40. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  41. package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
  42. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  43. package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
  44. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  45. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  46. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
  47. package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
  48. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  49. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  50. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  51. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  52. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  53. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  54. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  55. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  56. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  57. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  58. package/src/lib/booking/booking-cutoffs.ts +22 -0
  59. package/src/lib/booking/change-booking-server-preview.ts +92 -5
  60. package/src/lib/booking/change-flow-pricing.ts +12 -0
  61. package/src/lib/booking/i18n/messages/en.json +1 -0
  62. package/src/lib/booking/i18n/messages/fr.json +1 -0
  63. package/src/lib/booking-api.ts +76 -1
  64. package/test/change-booking-helpers.test.ts +1319 -7
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -123,7 +123,8 @@ export function PrivateShuttleCheckoutSection({
123
123
  partnerDeferredInvoiceSubmitLabel,
124
124
  }: PrivateShuttleCheckoutSectionProps) {
125
125
  const amountDueToday = depositInfo ? depositInfo.depositAmount : totalPrice;
126
- const formattedAmountDueToday = formatCurrencyAmount(amountDueToday, currency, locale);
126
+ const displayedTotal = isProviderChangeMode ? totalPrice : amountDueToday;
127
+ const formattedDisplayedTotal = formatCurrencyAmount(displayedTotal, currency, locale);
127
128
  const hasCancellationPolicies = Boolean(cancellationPolicies?.length || forcedCancellationPolicy);
128
129
 
129
130
  return (
@@ -131,7 +132,7 @@ export function PrivateShuttleCheckoutSection({
131
132
  <div className={`${styles.section} mt-6`}>
132
133
  <PriceSummary
133
134
  lines={priceSummaryLines}
134
- total={amountDueToday}
135
+ total={displayedTotal}
135
136
  currency={currency}
136
137
  locale={locale}
137
138
  taxAmount={effectiveTaxAmount}
@@ -158,7 +159,7 @@ export function PrivateShuttleCheckoutSection({
158
159
  size="sm"
159
160
  t={t}
160
161
  depositMode={
161
- depositInfo
162
+ !isProviderChangeMode && depositInfo
162
163
  ? {
163
164
  totalLabel: t('booking.deposit') || 'Deposit',
164
165
  balanceAmount: depositInfo.balanceAmount,
@@ -166,9 +167,15 @@ export function PrivateShuttleCheckoutSection({
166
167
  }
167
168
  : undefined
168
169
  }
169
- hideSubtotal={!!depositInfo}
170
+ hideSubtotal={!isProviderChangeMode && !!depositInfo}
170
171
  />
171
- {depositInfo && (
172
+ {isProviderChangeMode ? (
173
+ <div className="mt-3 rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900">
174
+ This is the updated booking total. Payments already made stay with this booking and are
175
+ applied first; only the remaining balance will be recorded as owing.
176
+ </div>
177
+ ) : null}
178
+ {!isProviderChangeMode && depositInfo && (
172
179
  <div className="deposit-notice mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
173
180
  <p className="text-xs font-medium">
174
181
  {(t('booking.depositPaymentNotice') !== 'booking.depositPaymentNotice'
@@ -190,51 +197,78 @@ export function PrivateShuttleCheckoutSection({
190
197
 
191
198
  <div className="border-t border-stone-200 pt-6 space-y-4">
192
199
  <div className="space-y-4">
193
- <div className={styles.contactGrid}>
194
- <div>
195
- <label className={styles.sectionLabel}>
196
- {t('booking.firstName')} <span className={styles.required}>*</span>
197
- </label>
198
- <input
199
- type="text"
200
- value={firstName}
201
- onChange={(e) => onFirstNameChange(e.target.value)}
202
- placeholder={t('booking.firstNamePlaceholder')}
203
- className={styles.input}
204
- />
200
+ {isProviderChangeMode ? (
201
+ <div className="rounded-lg border border-stone-200 bg-stone-50 p-4">
202
+ <p className="m-0 text-sm font-semibold text-stone-900">Existing booking contact</p>
203
+ <div className="mt-3 grid gap-3 text-sm sm:grid-cols-2">
204
+ <div>
205
+ <span className="block text-xs font-medium text-stone-500">Name</span>
206
+ <span className="text-stone-900">
207
+ {[firstName, lastName].filter(Boolean).join(' ') || 'Not provided'}
208
+ </span>
209
+ </div>
210
+ <div>
211
+ <span className="block text-xs font-medium text-stone-500">Email</span>
212
+ <span className="text-stone-900">{email || 'Not provided'}</span>
213
+ </div>
214
+ <div>
215
+ <span className="block text-xs font-medium text-stone-500">Phone</span>
216
+ <span className="text-stone-900">{phoneNumber || 'Not provided'}</span>
217
+ </div>
218
+ </div>
219
+ <p className="mb-0 mt-3 text-xs text-stone-600">
220
+ These customer details stay unchanged on the existing booking.
221
+ </p>
205
222
  </div>
206
- <div>
207
- <label className={styles.sectionLabel}>
208
- {t('booking.lastName')} <span className={styles.required}>*</span>
209
- </label>
210
- <input
211
- type="text"
212
- value={lastName}
213
- onChange={(e) => onLastNameChange(e.target.value)}
214
- placeholder={t('booking.lastNamePlaceholder')}
215
- className={styles.input}
223
+ ) : (
224
+ <>
225
+ <div className={styles.contactGrid}>
226
+ <div>
227
+ <label className={styles.sectionLabel}>
228
+ {t('booking.firstName')} <span className={styles.required}>*</span>
229
+ </label>
230
+ <input
231
+ type="text"
232
+ value={firstName}
233
+ onChange={(e) => onFirstNameChange(e.target.value)}
234
+ placeholder={t('booking.firstNamePlaceholder')}
235
+ className={styles.input}
236
+ />
237
+ </div>
238
+ <div>
239
+ <label className={styles.sectionLabel}>
240
+ {t('booking.lastName')} <span className={styles.required}>*</span>
241
+ </label>
242
+ <input
243
+ type="text"
244
+ value={lastName}
245
+ onChange={(e) => onLastNameChange(e.target.value)}
246
+ placeholder={t('booking.lastNamePlaceholder')}
247
+ className={styles.input}
248
+ />
249
+ </div>
250
+ </div>
251
+ <div>
252
+ <label className={styles.sectionLabel}>
253
+ {t('common.emailForConfirmation')} <span className={styles.required}>*</span>
254
+ </label>
255
+ <input
256
+ type="email"
257
+ value={email}
258
+ onChange={(e) => onEmailChange(e.target.value)}
259
+ placeholder={t('common.emailPlaceholder')}
260
+ className={styles.input}
261
+ />
262
+ </div>
263
+ <CheckoutOptionalPhoneFields
264
+ idPrefix="shuttle"
265
+ labelClassName={styles.sectionLabel}
266
+ phoneNumber={phoneNumber}
267
+ onPhoneNumberChange={onPhoneNumberChange}
268
+ t={t}
216
269
  />
217
- </div>
218
- </div>
219
- <div>
220
- <label className={styles.sectionLabel}>
221
- {t('common.emailForConfirmation')} <span className={styles.required}>*</span>
222
- </label>
223
- <input
224
- type="email"
225
- value={email}
226
- onChange={(e) => onEmailChange(e.target.value)}
227
- placeholder={t('common.emailPlaceholder')}
228
- className={styles.input}
229
- />
230
- </div>
231
- <CheckoutOptionalPhoneFields
232
- idPrefix="shuttle"
233
- labelClassName={styles.sectionLabel}
234
- phoneNumber={phoneNumber}
235
- onPhoneNumberChange={onPhoneNumberChange}
236
- t={t}
237
- />
270
+ </>
271
+ )}
238
272
  {hasCancellationPolicies && (
239
273
  <div>
240
274
  <CancellationPolicySelector
@@ -250,10 +284,17 @@ export function PrivateShuttleCheckoutSection({
250
284
  onPolicySelect={onCancellationPolicySelect}
251
285
  forcedPolicy={forcedCancellationPolicy}
252
286
  rowSubtitle={
253
- <>
254
- <b>Your deposit today is 100% refundable</b> - only once we align on an itinerary
255
- that you approve, will your deposit be locked in.
256
- </>
287
+ isProviderChangeMode ? (
288
+ <>
289
+ This policy applies to the updated private booking. Payments already made stay
290
+ on the existing booking.
291
+ </>
292
+ ) : (
293
+ <>
294
+ <b>Your deposit today is 100% refundable</b> - only once we align on an itinerary
295
+ that you approve, will your deposit be locked in.
296
+ </>
297
+ )
257
298
  }
258
299
  />
259
300
  </div>
@@ -327,8 +368,8 @@ export function PrivateShuttleCheckoutSection({
327
368
  ? 'Processing...'
328
369
  : (t('booking.creatingReservation') || 'Creating reservation...')
329
370
  : isProviderChangeMode
330
- ? `Change booking (${formattedAmountDueToday})`
331
- : `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formattedAmountDueToday})`}
371
+ ? `Change booking (updated total ${formattedDisplayedTotal})`
372
+ : `${partnerDeferredInvoiceSubmitLabel || t('booking.continueToPayment') || 'Continue to Payment'} (${formattedDisplayedTotal})`}
332
373
  </button>
333
374
  <p className={styles.secureNote}>
334
375
  {t('booking.securePayment') || 'Secure payment powered by Stripe'}
@@ -0,0 +1,44 @@
1
+ import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
2
+
3
+ export type AdminAdjustmentComponentOption = {
4
+ componentId: string;
5
+ label: string;
6
+ amount: number;
7
+ type: string;
8
+ };
9
+
10
+ const NON_SELECTABLE_TYPES = new Set([
11
+ 'TAX',
12
+ 'ROUNDING',
13
+ 'SYSTEM',
14
+ 'PROMO_CODE',
15
+ 'GIFT_CARD',
16
+ 'DEAL',
17
+ 'DISCOUNT',
18
+ 'BOOKING_CHANGE',
19
+ ]);
20
+
21
+ export function selectableAdminAdjustmentComponentsFromQuote(
22
+ quote: ChangeBookingQuoteResponse,
23
+ ): AdminAdjustmentComponentOption[] {
24
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
25
+ const lines = pricingQuote?.amendment?.updatedReceipt?.lineItems ?? [];
26
+ const byId = new Map<string, AdminAdjustmentComponentOption>();
27
+
28
+ lines.forEach((line) => {
29
+ const componentId = line.identity?.componentId?.trim() || line.reference?.trim();
30
+ const type = line.type?.trim().toUpperCase() || 'ITEM';
31
+ const amount = Number(line.amount ?? 0);
32
+ if (!componentId || !Number.isFinite(amount) || amount <= 0 || NON_SELECTABLE_TYPES.has(type)) {
33
+ return;
34
+ }
35
+ byId.set(componentId, {
36
+ componentId,
37
+ label: line.label?.trim() || type,
38
+ amount,
39
+ type,
40
+ });
41
+ });
42
+
43
+ return [...byId.values()];
44
+ }
@@ -0,0 +1,50 @@
1
+ import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
2
+
3
+ export type AdminAdjustmentTaxBehavior = AdminCustomReceiptLine['taxBehavior'];
4
+
5
+ export type AdminAdjustmentTaxBehaviorOption = {
6
+ value: AdminAdjustmentTaxBehavior;
7
+ label: string;
8
+ };
9
+
10
+ const CHARGE_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
11
+ { value: 'TAXABLE', label: 'Taxable' },
12
+ { value: 'NON_TAXABLE', label: 'Non-taxable' },
13
+ ];
14
+
15
+ const CREDIT_OPTIONS: readonly AdminAdjustmentTaxBehaviorOption[] = [
16
+ { value: 'PRE_TAX_DISCOUNT', label: 'Pre-tax discount' },
17
+ { value: 'POST_TAX_DISCOUNT', label: 'Post-tax discount' },
18
+ ];
19
+
20
+ export function adminAdjustmentTaxBehaviorOptions(
21
+ amountSign: AdminCustomReceiptLine['amountSign'],
22
+ ): readonly AdminAdjustmentTaxBehaviorOption[] {
23
+ return amountSign === -1 ? CREDIT_OPTIONS : CHARGE_OPTIONS;
24
+ }
25
+
26
+ export function normalizeAdminAdjustmentTaxBehavior(
27
+ amountSign: AdminCustomReceiptLine['amountSign'],
28
+ current: AdminAdjustmentTaxBehavior,
29
+ ): AdminAdjustmentTaxBehavior {
30
+ const options = adminAdjustmentTaxBehaviorOptions(amountSign);
31
+ return options.some((option) => option.value === current)
32
+ ? current
33
+ : options[0].value;
34
+ }
35
+
36
+ export type AdminAdjustmentScope = AdminCustomReceiptLine['scope'];
37
+
38
+ /**
39
+ * New custom lines start as charges against added value. A credit against that
40
+ * scope has no basis until the amendment also adds priced value, so switching
41
+ * direction defaults to the existing active tickets instead.
42
+ */
43
+ export function normalizeAdminAdjustmentScopeForSign(
44
+ amountSign: AdminCustomReceiptLine['amountSign'],
45
+ current: AdminAdjustmentScope,
46
+ ): AdminAdjustmentScope {
47
+ return amountSign === -1 && current === 'POSITIVE_AMENDMENT_VALUE'
48
+ ? 'CURRENT_ACTIVE_TICKETS'
49
+ : current;
50
+ }
@@ -66,6 +66,16 @@ export function buildChangeBookingMinimumQuantities({
66
66
  return minimums;
67
67
  }
68
68
 
69
+ export function shouldLockExistingAddOnQuantities({
70
+ isCustomerSelfServeChange,
71
+ isAdmin,
72
+ }: {
73
+ isCustomerSelfServeChange: boolean;
74
+ isAdmin: boolean;
75
+ }): boolean {
76
+ return isCustomerSelfServeChange && !isAdmin;
77
+ }
78
+
69
79
  export function resolveInitialChangeProductOptionId({
70
80
  initialProductId,
71
81
  initialProductOptionId,
@@ -1,4 +1,9 @@
1
- import type { Availability, Product, ReturnOption } from '../../lib/booking-api';
1
+ import type {
2
+ AdminPromoApplicationScope,
3
+ Availability,
4
+ Product,
5
+ ReturnOption,
6
+ } from '../../lib/booking-api';
2
7
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
3
8
 
4
9
  export type AdminChangeBookingItem = { category: string; count: number };
@@ -29,10 +34,12 @@ export interface BuildAdminChangeProviderPayloadParams {
29
34
  cancellationPolicyId: string | null;
30
35
  initialCancellationPolicyId?: string | null;
31
36
  appliedPromoCode: string | null;
37
+ promoApplicationScope?: AdminPromoApplicationScope;
32
38
  newTotalAmount: number;
33
39
  providerPricingOverrides: AdminChangeProviderLineOverride[];
34
40
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
35
41
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
42
+ adminReverseAdjustmentIds?: string[];
36
43
  refundDispositionsByOperationId: NonNullable<
37
44
  ProviderDashboardChangeBookingPayload['refundDispositionsByOperationId']
38
45
  >;
@@ -56,10 +63,12 @@ export function buildAdminChangeProviderPayload({
56
63
  cancellationPolicyId,
57
64
  initialCancellationPolicyId,
58
65
  appliedPromoCode,
66
+ promoApplicationScope = 'CHANGE_ONLY',
59
67
  newTotalAmount,
60
68
  providerPricingOverrides,
61
69
  mergedProviderAdditionalAdjustments,
62
70
  adminStructuredAdjustments,
71
+ adminReverseAdjustmentIds = [],
63
72
  refundDispositionsByOperationId,
64
73
  pricingV2QuoteId,
65
74
  pricingV2QuotedTotal,
@@ -92,6 +101,7 @@ export function buildAdminChangeProviderPayload({
92
101
  addOnSelections,
93
102
  cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
94
103
  promoCode: appliedPromoCode ?? null,
104
+ promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
95
105
  newTotalAmount,
96
106
  additionalHoursCount: null,
97
107
  pricingAdjustment:
@@ -106,6 +116,7 @@ export function buildAdminChangeProviderPayload({
106
116
  : undefined,
107
117
  authoritativeReceipt: providerApplyAuthoritativeReceipt,
108
118
  structuredAdjustments: adminStructuredAdjustments,
119
+ reverseAdjustmentIds: adminReverseAdjustmentIds,
109
120
  refundDispositionsByOperationId,
110
121
  pricingV2QuoteId: pricingV2QuoteId ?? null,
111
122
  pricingV2QuotedTotal: pricingV2QuotedTotal ?? null,
@@ -1,4 +1,4 @@
1
- import type { Availability } from '../../lib/booking-api';
1
+ import type { AdminPromoApplicationScope, Availability } from '../../lib/booking-api';
2
2
  import { getAvailabilityOptionId } from './change-booking-flow-helpers';
3
3
 
4
4
  interface AdminChangeQuoteRequestKeyInput {
@@ -12,11 +12,39 @@ interface AdminChangeQuoteRequestKeyInput {
12
12
  addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
13
13
  adminCustomReceiptLines: unknown[];
14
14
  cancellationPolicyId?: string | null;
15
+ promoCode?: string | null;
16
+ promoApplicationScope?: AdminPromoApplicationScope | null;
15
17
  structuredAdjustments?: unknown[];
18
+ reverseAdjustmentIds?: string[];
16
19
  refundDispositionsByOperationId?: Record<string, string>;
17
20
  useAdminFeAuthoritativeQuote: boolean;
18
21
  }
19
22
 
23
+ interface AdminChangeQuoteRequestGateInput {
24
+ isCustomerSelfServeChange: boolean;
25
+ hasEffectiveChangeSelection: boolean;
26
+ hasSelectedAvailability: boolean;
27
+ bookingReference?: string | null;
28
+ lastName: string;
29
+ missingRequiredReturnSelection: boolean;
30
+ }
31
+
32
+ /**
33
+ * Whether the current admin inputs are complete enough to request an authoritative quote.
34
+ * `hasEffectiveChangeSelection` deliberately includes adjustment-only changes, even when
35
+ * product, tickets, return, pickup, and itinerary are otherwise unchanged.
36
+ */
37
+ export function canRequestAdminChangeQuote(input: AdminChangeQuoteRequestGateInput): boolean {
38
+ return Boolean(
39
+ input.isCustomerSelfServeChange &&
40
+ input.hasEffectiveChangeSelection &&
41
+ input.hasSelectedAvailability &&
42
+ input.bookingReference?.trim() &&
43
+ input.lastName.trim() &&
44
+ !input.missingRequiredReturnSelection,
45
+ );
46
+ }
47
+
20
48
  /**
21
49
  * Identity of a price request. This must contain user-controlled quote inputs only.
22
50
  * Server/display-derived receipt state must never invalidate the response that produced it.
@@ -36,10 +64,22 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
36
64
  addOnSelections: input.addOnSelections,
37
65
  adminCustomReceiptLines: input.adminCustomReceiptLines,
38
66
  cancellationPolicyId: input.cancellationPolicyId ?? null,
67
+ promoCode: input.promoCode?.trim().toUpperCase() ?? null,
68
+ promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
39
69
  structuredAdjustments: input.structuredAdjustments ?? [],
70
+ reverseAdjustmentIds: [...(input.reverseAdjustmentIds ?? [])].sort(),
40
71
  refundDispositionsByOperationId: Object.fromEntries(
41
72
  Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
42
73
  ),
43
74
  useAdminFeAuthoritativeQuote: input.useAdminFeAuthoritativeQuote,
44
75
  });
45
76
  }
77
+
78
+ /** A completed request is reusable only while its quote is still published in UI state. */
79
+ export function canReuseCompletedAdminChangeQuote(
80
+ inputKey: string,
81
+ lastCompletedInputKey: string | null,
82
+ hasPublishedQuote: boolean,
83
+ ): boolean {
84
+ return Boolean(inputKey) && lastCompletedInputKey === inputKey && hasPublishedQuote;
85
+ }
@@ -0,0 +1,49 @@
1
+ import type { PriceSummaryLine } from './PriceSummary';
2
+
3
+ function isSelectedPromoLine(line: PriceSummaryLine, promoCode: string): boolean {
4
+ if (line.kind !== 'line') return false;
5
+ const type = String(line.type ?? '').trim().toUpperCase();
6
+ if (type === 'PROMO_CODE') return true;
7
+ if (type !== 'DISCOUNT') return false;
8
+
9
+ const label = line.label.trim().toUpperCase();
10
+ return label.startsWith('DISCOUNT ·') ||
11
+ label.startsWith('PROMO ') ||
12
+ label.includes(promoCode);
13
+ }
14
+
15
+ /**
16
+ * Keep component-level promo allocations in the server quote/ledger while presenting
17
+ * the selected promo as one customer-facing row in the admin amendment summary.
18
+ */
19
+ export function collapseAdminPromoLines(
20
+ lines: PriceSummaryLine[],
21
+ promoCode: string | null | undefined,
22
+ ): PriceSummaryLine[] {
23
+ const normalizedCode = promoCode?.trim().toUpperCase() ?? '';
24
+ if (!normalizedCode) return lines;
25
+
26
+ const promoIndexes = lines
27
+ .map((line, index) => isSelectedPromoLine(line, normalizedCode) ? index : -1)
28
+ .filter((index) => index >= 0);
29
+ if (promoIndexes.length === 0) return lines;
30
+
31
+ const promoIndexSet = new Set(promoIndexes);
32
+ const firstPromoIndex = promoIndexes[0];
33
+ const promoAmount = Math.round(promoIndexes.reduce((sum, index) => {
34
+ const line = lines[index];
35
+ return sum + (line.kind === 'line' ? line.amount : 0);
36
+ }, 0) * 100) / 100;
37
+
38
+ return lines.flatMap((line, index) => {
39
+ if (index === firstPromoIndex) {
40
+ return [{
41
+ kind: 'line' as const,
42
+ label: `Promo: ${normalizedCode}`,
43
+ amount: promoAmount,
44
+ type: 'PROMO_CODE',
45
+ }];
46
+ }
47
+ return promoIndexSet.has(index) ? [] : [line];
48
+ });
49
+ }
@@ -0,0 +1,109 @@
1
+ import type {
2
+ AdminAmendmentRefundDisposition,
3
+ AdminPromoApplicationScope,
4
+ Availability,
5
+ ChangeBookingQuoteRequest,
6
+ ChangeBookingQuoteResponse,
7
+ } from '../../lib/booking-api';
8
+ import { quoteAdminChangeBookingV2 } from '../../lib/booking-api';
9
+ import type { Currency } from './CurrencySwitcher';
10
+ import {
11
+ sliceAndMergeChangeQuoteForUi,
12
+ type ChangeBookingMergedQuoteState,
13
+ } from './change-booking-quote-state';
14
+
15
+ export type AdminChangeV2QuoteRequest = ChangeBookingQuoteRequest & {
16
+ cancellationPolicyId?: string | null;
17
+ includeSelectableAdjustmentComponents?: boolean;
18
+ };
19
+
20
+ export interface BuildAdminChangeV2QuoteRequestParams {
21
+ bookingReference: string;
22
+ lastName: string;
23
+ parentProductId: string;
24
+ optionId: string;
25
+ selectedAvailability: Availability;
26
+ pickupLocationId: string | null;
27
+ returnAvailabilityId: string | null;
28
+ bookingItems: Array<{ category: string; count: number }>;
29
+ addOnSelections: Array<{ addOnId: string; variantId?: string; quantity?: number }>;
30
+ cancellationPolicyId: string | null;
31
+ promoCode?: string | null;
32
+ promoApplicationScope?: AdminPromoApplicationScope;
33
+ structuredAdjustments: NonNullable<ChangeBookingQuoteRequest['structuredAdjustments']>;
34
+ reverseAdjustmentIds?: string[];
35
+ includeSelectableAdjustmentComponents?: boolean;
36
+ refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
37
+ previousPassengerCount: number;
38
+ previousAvailabilityId: string | null;
39
+ previousReturnAvailabilityId: string | null;
40
+ }
41
+
42
+ /**
43
+ * Canonical server-authoritative provider/admin quote request.
44
+ * Preview and checkout must use the same endpoint and request shape so checkout cannot
45
+ * replace a valid Pricing V2 quote with a legacy response that has no quote id.
46
+ */
47
+ export function buildAdminChangeV2QuoteRequest({
48
+ bookingReference,
49
+ lastName,
50
+ parentProductId,
51
+ optionId,
52
+ selectedAvailability,
53
+ pickupLocationId,
54
+ returnAvailabilityId,
55
+ bookingItems,
56
+ addOnSelections,
57
+ cancellationPolicyId,
58
+ promoCode,
59
+ promoApplicationScope = 'CHANGE_ONLY',
60
+ structuredAdjustments,
61
+ reverseAdjustmentIds = [],
62
+ includeSelectableAdjustmentComponents = false,
63
+ refundDispositionsByOperationId,
64
+ previousPassengerCount,
65
+ previousAvailabilityId,
66
+ previousReturnAvailabilityId,
67
+ }: BuildAdminChangeV2QuoteRequestParams): AdminChangeV2QuoteRequest {
68
+ return {
69
+ bookingReference: bookingReference.trim(),
70
+ lastName: lastName.trim(),
71
+ newProductId: optionId,
72
+ newParentProductId: parentProductId,
73
+ newDateTime: selectedAvailability.dateTime,
74
+ newAvailabilityId: selectedAvailability.availabilityId || null,
75
+ newPickupLocationId: pickupLocationId || null,
76
+ newReturnAvailabilityId: returnAvailabilityId,
77
+ newPassengerCounts: bookingItems,
78
+ // Admin owns the complete target selection. [] intentionally clears all add-ons.
79
+ newAddOnSelections: addOnSelections,
80
+ cancellationPolicyId,
81
+ promoCode: promoCode?.trim() || null,
82
+ promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
83
+ structuredAdjustments,
84
+ reverseAdjustmentIds,
85
+ ...(includeSelectableAdjustmentComponents
86
+ ? { includeSelectableAdjustmentComponents: true }
87
+ : {}),
88
+ ...(Object.keys(refundDispositionsByOperationId).length > 0
89
+ ? { refundDispositionsByOperationId }
90
+ : {}),
91
+ capacitySeatCredit: {
92
+ enabled: true,
93
+ previousPassengerCount,
94
+ previousAvailabilityId,
95
+ previousReturnAvailabilityId,
96
+ },
97
+ };
98
+ }
99
+
100
+ export async function quoteAdminChangeV2ForUi(
101
+ request: AdminChangeV2QuoteRequest,
102
+ fallbackCart: { total: number; subtotal: number; tax: number },
103
+ currency: Currency,
104
+ quoteFn: (request: ChangeBookingQuoteRequest) => Promise<ChangeBookingQuoteResponse> =
105
+ quoteAdminChangeBookingV2,
106
+ ): Promise<ChangeBookingMergedQuoteState> {
107
+ const quote = await quoteFn(request);
108
+ return sliceAndMergeChangeQuoteForUi(quote, fallbackCart, currency).mergedQuote;
109
+ }