@ticketboothapp/booking 1.2.166 → 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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +2 -0
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +95 -30
  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-provider-payload.ts +9 -1
  12. package/src/components/booking/admin-change-quote-request-key.ts +14 -1
  13. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  14. package/src/components/booking/admin-change-v2-quote-request.ts +101 -0
  15. package/src/components/booking/admin-refund-decision-context.ts +85 -0
  16. package/src/components/booking/availability-date-selection.ts +16 -0
  17. package/src/components/booking/provider-dashboard-change-booking.ts +2 -1
  18. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  19. package/src/components/booking/useAdminChangeCheckoutController.ts +40 -1
  20. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +2 -2
  21. package/src/components/booking/useAdminChangeQuotePreview.ts +72 -43
  22. package/src/components/booking/useBookingAvailabilityAddOns.ts +7 -2
  23. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  24. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  25. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  26. package/src/lib/booking/booking-cutoffs.ts +22 -0
  27. package/src/lib/booking/change-booking-server-preview.ts +75 -5
  28. package/src/lib/booking-api.ts +12 -1
  29. package/test/change-booking-helpers.test.ts +466 -4
@@ -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,6 +34,7 @@ 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[];
@@ -56,6 +62,7 @@ export function buildAdminChangeProviderPayload({
56
62
  cancellationPolicyId,
57
63
  initialCancellationPolicyId,
58
64
  appliedPromoCode,
65
+ promoApplicationScope = 'CHANGE_ONLY',
59
66
  newTotalAmount,
60
67
  providerPricingOverrides,
61
68
  mergedProviderAdditionalAdjustments,
@@ -92,6 +99,7 @@ export function buildAdminChangeProviderPayload({
92
99
  addOnSelections,
93
100
  cancellationPolicyId: cancellationPolicyId ?? initialCancellationPolicyId ?? null,
94
101
  promoCode: appliedPromoCode ?? null,
102
+ promoApplicationScope: appliedPromoCode ? promoApplicationScope : null,
95
103
  newTotalAmount,
96
104
  additionalHoursCount: null,
97
105
  pricingAdjustment:
@@ -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,6 +12,8 @@ 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[];
16
18
  refundDispositionsByOperationId?: Record<string, string>;
17
19
  useAdminFeAuthoritativeQuote: boolean;
@@ -36,6 +38,8 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
36
38
  addOnSelections: input.addOnSelections,
37
39
  adminCustomReceiptLines: input.adminCustomReceiptLines,
38
40
  cancellationPolicyId: input.cancellationPolicyId ?? null,
41
+ promoCode: input.promoCode?.trim().toUpperCase() ?? null,
42
+ promoApplicationScope: input.promoCode ? (input.promoApplicationScope ?? 'CHANGE_ONLY') : null,
39
43
  structuredAdjustments: input.structuredAdjustments ?? [],
40
44
  refundDispositionsByOperationId: Object.fromEntries(
41
45
  Object.entries(input.refundDispositionsByOperationId ?? {}).sort(([a], [b]) => a.localeCompare(b)),
@@ -43,3 +47,12 @@ export function buildAdminChangeQuoteRequestKey(input: AdminChangeQuoteRequestKe
43
47
  useAdminFeAuthoritativeQuote: input.useAdminFeAuthoritativeQuote,
44
48
  });
45
49
  }
50
+
51
+ /** A completed request is reusable only while its quote is still published in UI state. */
52
+ export function canReuseCompletedAdminChangeQuote(
53
+ inputKey: string,
54
+ lastCompletedInputKey: string | null,
55
+ hasPublishedQuote: boolean,
56
+ ): boolean {
57
+ return Boolean(inputKey) && lastCompletedInputKey === inputKey && hasPublishedQuote;
58
+ }
@@ -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,101 @@
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
+ structuredAdjustments?: unknown[];
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: unknown[];
34
+ refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
35
+ previousPassengerCount: number;
36
+ previousAvailabilityId: string | null;
37
+ previousReturnAvailabilityId: string | null;
38
+ }
39
+
40
+ /**
41
+ * Canonical server-authoritative provider/admin quote request.
42
+ * Preview and checkout must use the same endpoint and request shape so checkout cannot
43
+ * replace a valid Pricing V2 quote with a legacy response that has no quote id.
44
+ */
45
+ export function buildAdminChangeV2QuoteRequest({
46
+ bookingReference,
47
+ lastName,
48
+ parentProductId,
49
+ optionId,
50
+ selectedAvailability,
51
+ pickupLocationId,
52
+ returnAvailabilityId,
53
+ bookingItems,
54
+ addOnSelections,
55
+ cancellationPolicyId,
56
+ promoCode,
57
+ promoApplicationScope = 'CHANGE_ONLY',
58
+ structuredAdjustments,
59
+ refundDispositionsByOperationId,
60
+ previousPassengerCount,
61
+ previousAvailabilityId,
62
+ previousReturnAvailabilityId,
63
+ }: BuildAdminChangeV2QuoteRequestParams): AdminChangeV2QuoteRequest {
64
+ return {
65
+ bookingReference: bookingReference.trim(),
66
+ lastName: lastName.trim(),
67
+ newProductId: optionId,
68
+ newParentProductId: parentProductId,
69
+ newDateTime: selectedAvailability.dateTime,
70
+ newAvailabilityId: selectedAvailability.availabilityId || null,
71
+ newPickupLocationId: pickupLocationId || null,
72
+ newReturnAvailabilityId: returnAvailabilityId,
73
+ newPassengerCounts: bookingItems,
74
+ // Admin owns the complete target selection. [] intentionally clears all add-ons.
75
+ newAddOnSelections: addOnSelections,
76
+ cancellationPolicyId,
77
+ promoCode: promoCode?.trim() || null,
78
+ promoApplicationScope: promoCode?.trim() ? promoApplicationScope : null,
79
+ structuredAdjustments,
80
+ ...(Object.keys(refundDispositionsByOperationId).length > 0
81
+ ? { refundDispositionsByOperationId }
82
+ : {}),
83
+ capacitySeatCredit: {
84
+ enabled: true,
85
+ previousPassengerCount,
86
+ previousAvailabilityId,
87
+ previousReturnAvailabilityId,
88
+ },
89
+ };
90
+ }
91
+
92
+ export async function quoteAdminChangeV2ForUi(
93
+ request: AdminChangeV2QuoteRequest,
94
+ fallbackCart: { total: number; subtotal: number; tax: number },
95
+ currency: Currency,
96
+ quoteFn: (request: ChangeBookingQuoteRequest) => Promise<ChangeBookingQuoteResponse> =
97
+ quoteAdminChangeBookingV2,
98
+ ): Promise<ChangeBookingMergedQuoteState> {
99
+ const quote = await quoteFn(request);
100
+ return sliceAndMergeChangeQuoteForUi(quote, fallbackCart, currency).mergedQuote;
101
+ }
@@ -0,0 +1,85 @@
1
+ import type {
2
+ AdminAmendmentOperationSnapshot,
3
+ AdminAmendmentRefundDisposition,
4
+ } from '../../lib/booking-api';
5
+
6
+ export interface AdminRefundDecisionContext {
7
+ selectionKey: string;
8
+ operations: AdminAmendmentOperationSnapshot[];
9
+ allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
10
+ removedValueByOperationId: Record<string, number>;
11
+ }
12
+
13
+ interface MergeAdminRefundDecisionContextParams {
14
+ current: AdminRefundDecisionContext | null;
15
+ selectionKey: string;
16
+ operations: AdminAmendmentOperationSnapshot[];
17
+ allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
18
+ removedValueByOperationId: Record<string, number>;
19
+ }
20
+
21
+ /**
22
+ * Refund-decision quote responses are incremental: after one operation is answered,
23
+ * the server can return only the operations that still need an answer. Keep all of
24
+ * the operations for the current booking selection so an answered operation does
25
+ * not disappear from the next request and get requested again.
26
+ */
27
+ export function mergeAdminRefundDecisionContext({
28
+ current,
29
+ selectionKey,
30
+ operations,
31
+ allowedByOperationId,
32
+ removedValueByOperationId,
33
+ }: MergeAdminRefundDecisionContextParams): AdminRefundDecisionContext | null {
34
+ const sameSelection = current?.selectionKey === selectionKey ? current : null;
35
+ if (!sameSelection && operations.length === 0) return null;
36
+
37
+ const operationsById = new Map<string, AdminAmendmentOperationSnapshot>();
38
+ for (const operation of sameSelection?.operations ?? []) {
39
+ operationsById.set(operation.operationId, operation);
40
+ }
41
+ for (const operation of operations) {
42
+ operationsById.set(operation.operationId, operation);
43
+ }
44
+
45
+ const relevantOperationIds = new Set(operationsById.keys());
46
+ const mergedAllowed = {
47
+ ...(sameSelection?.allowedByOperationId ?? {}),
48
+ ...allowedByOperationId,
49
+ };
50
+ const mergedRemovedValues = {
51
+ ...(sameSelection?.removedValueByOperationId ?? {}),
52
+ ...removedValueByOperationId,
53
+ };
54
+
55
+ return {
56
+ selectionKey,
57
+ operations: [...operationsById.values()],
58
+ allowedByOperationId: Object.fromEntries(
59
+ Object.entries(mergedAllowed).filter(([operationId]) =>
60
+ relevantOperationIds.has(operationId),
61
+ ),
62
+ ),
63
+ removedValueByOperationId: Object.fromEntries(
64
+ Object.entries(mergedRemovedValues).filter(([operationId]) =>
65
+ relevantOperationIds.has(operationId),
66
+ ),
67
+ ),
68
+ };
69
+ }
70
+
71
+ export function selectRefundDispositionsForOperations(
72
+ context: AdminRefundDecisionContext | null,
73
+ additionalOperations: AdminAmendmentOperationSnapshot[],
74
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
75
+ ): Record<string, AdminAmendmentRefundDisposition> {
76
+ const relevantOperationIds = new Set([
77
+ ...(context?.operations ?? []).map((operation) => operation.operationId),
78
+ ...additionalOperations.map((operation) => operation.operationId),
79
+ ]);
80
+ return Object.fromEntries(
81
+ Object.entries(dispositionsByOperationId).filter(([operationId]) =>
82
+ relevantOperationIds.has(operationId),
83
+ ),
84
+ );
85
+ }
@@ -0,0 +1,16 @@
1
+ import type { Availability } from '../../lib/booking-api';
2
+
3
+ export function findFirstDateWithBookableAvailability(
4
+ dates: string[],
5
+ getRowsForDate: (date: string) => Availability[],
6
+ isBookable: (availability: Availability) => boolean,
7
+ ): string | undefined {
8
+ return dates.find((date) => getRowsForDate(date).some(isBookable));
9
+ }
10
+
11
+ export function selectedDateHasVisibleAvailability(
12
+ selectedDate: string,
13
+ getRowsForDate: (date: string) => Availability[],
14
+ ): boolean {
15
+ return selectedDate !== '' && getRowsForDate(selectedDate).length > 0;
16
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Currency } from './CurrencySwitcher';
2
- import type { ItineraryDisplayStep } from '../../lib/booking-api';
2
+ import type { AdminPromoApplicationScope, ItineraryDisplayStep } from '../../lib/booking-api';
3
3
 
4
4
  export type AdminAmendmentAdjustmentMode =
5
5
  | 'FIXED_AMOUNT'
@@ -52,6 +52,7 @@ export type ProviderDashboardChangeBookingPayload = {
52
52
  addOnSelections?: Array<{ addOnId: string; variantId?: string; quantity?: number }> | null;
53
53
  cancellationPolicyId?: string | null;
54
54
  promoCode?: string | null;
55
+ promoApplicationScope?: AdminPromoApplicationScope | null;
55
56
  newTotalAmount?: number;
56
57
  additionalHoursCount?: number | null;
57
58
  pricingAdjustment?: {
@@ -18,7 +18,10 @@ import {
18
18
  type Product,
19
19
  type ReturnOption,
20
20
  } from '../../lib/booking-api';
21
- import { filterAvailabilitiesAfterPublicBookingCutoff } from '../../lib/booking/booking-cutoffs';
21
+ import {
22
+ filterAvailabilitiesAfterCurrentTime,
23
+ filterAvailabilitiesAfterPublicBookingCutoff,
24
+ } from '../../lib/booking/booking-cutoffs';
22
25
  import { shouldReplacePricingConfig } from '../../lib/booking/pricing-config';
23
26
  import { useAvailabilitiesCache, buildAvailabilitiesCacheKey } from '../../contexts/AvailabilitiesCacheContext';
24
27
  import {
@@ -628,7 +631,7 @@ export function useStandardBookingAvailability({
628
631
  });
629
632
 
630
633
  return isAdmin
631
- ? currentOrFutureAvailabilities
634
+ ? filterAvailabilitiesAfterCurrentTime(currentOrFutureAvailabilities, bookingCutoffNow)
632
635
  : filterAvailabilitiesAfterPublicBookingCutoff(
633
636
  currentOrFutureAvailabilities,
634
637
  bookingCutoffNow,
@@ -13,6 +13,7 @@ import {
13
13
  type AdminAmendmentOperationSnapshot,
14
14
  type AdminAmendmentRefundDisposition,
15
15
  type AdminFeAuthoritativeReceipt,
16
+ type AdminPromoApplicationScope,
16
17
  type Availability,
17
18
  type ChangeBookingQuotePricingDriftDetail,
18
19
  type ChangeBookingQuoteTicketPricingTrace,
@@ -67,6 +68,10 @@ import {
67
68
  confirmFreeAdminCustomerChange,
68
69
  quoteAdminCustomerChangeForCheckout,
69
70
  } from './admin-change-customer-quote-runner';
71
+ import {
72
+ buildAdminChangeV2QuoteRequest,
73
+ quoteAdminChangeV2ForUi,
74
+ } from './admin-change-v2-quote-request';
70
75
  import {
71
76
  reservationHoldHasExpired,
72
77
  RESERVATION_HOLD_EXPIRED_MESSAGE,
@@ -136,6 +141,7 @@ export interface UseAdminChangeCheckoutControllerParams {
136
141
  onChangeBooking?: ChangeBookingFlowProps['onChangeBooking'];
137
142
  cancellationPolicyId: string | null;
138
143
  appliedPromoCode: string | null;
144
+ promoApplicationScope: AdminPromoApplicationScope;
139
145
  displayChangeFlowProposedTotalWithEditableLines: number;
140
146
  providerPricingOverrides: AdminChangeProviderLineOverride[];
141
147
  mergedProviderAdditionalAdjustments: AdminChangeProviderAdditionalAdjustment[];
@@ -237,6 +243,7 @@ export function useAdminChangeCheckoutController({
237
243
  onChangeBooking,
238
244
  cancellationPolicyId,
239
245
  appliedPromoCode,
246
+ promoApplicationScope,
240
247
  displayChangeFlowProposedTotalWithEditableLines,
241
248
  providerPricingOverrides,
242
249
  mergedProviderAdditionalAdjustments,
@@ -358,6 +365,7 @@ export function useAdminChangeCheckoutController({
358
365
  cancellationPolicyId,
359
366
  initialCancellationPolicyId: initialValues?.cancellationPolicyId ?? null,
360
367
  appliedPromoCode,
368
+ promoApplicationScope,
361
369
  newTotalAmount: displayChangeFlowProposedTotalWithEditableLines,
362
370
  providerPricingOverrides,
363
371
  mergedProviderAdditionalAdjustments,
@@ -461,7 +469,38 @@ export function useAdminChangeCheckoutController({
461
469
  let confirmedChangeAmountDueForCheckout: number | null = null;
462
470
  let authoritativeChangeQuoteForCheckout = latestChangeQuote;
463
471
 
464
- if (isCustomerSelfServeChange) {
472
+ if (isProviderDashboardChange && isChangeBookingContext) {
473
+ const bookingReference = initialValues?.bookingReference?.trim();
474
+ if (!bookingReference) throw new Error('Missing booking reference.');
475
+ authoritativeChangeQuoteForCheckout = await quoteAdminChangeV2ForUi(
476
+ buildAdminChangeV2QuoteRequest({
477
+ bookingReference,
478
+ lastName,
479
+ parentProductId: product.productId,
480
+ optionId: availabilityProductOptionId,
481
+ selectedAvailability,
482
+ pickupLocationId,
483
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
484
+ bookingItems,
485
+ addOnSelections,
486
+ cancellationPolicyId,
487
+ promoCode: appliedPromoCode,
488
+ promoApplicationScope,
489
+ structuredAdjustments: adminStructuredAdjustments,
490
+ refundDispositionsByOperationId,
491
+ previousPassengerCount: changeFlowInitialTicketCount,
492
+ previousAvailabilityId: initialValues?.availabilityId ?? null,
493
+ previousReturnAvailabilityId: initialValues?.returnAvailabilityId ?? null,
494
+ }),
495
+ {
496
+ total: changeFlowNewBookingTotal,
497
+ subtotal: effectiveSubtotalForCheckout,
498
+ tax: effectiveTax,
499
+ },
500
+ currency,
501
+ );
502
+ setLatestChangeQuote(authoritativeChangeQuoteForCheckout);
503
+ } else if (isCustomerSelfServeChange) {
465
504
  const quoteResult = await quoteAdminCustomerChangeForCheckout({
466
505
  bookingReference: initialValues?.bookingReference,
467
506
  lastName,
@@ -326,9 +326,9 @@ export function useAdminChangeQuoteDisplayState({
326
326
  productChanged: selectedParentProductChanged,
327
327
  });
328
328
  const hasChangeSelection =
329
- isProviderDashboardChange
329
+ (isProviderDashboardChange
330
330
  ? changeSelectionDetails.hasOperationalChangesFromInitial
331
- : changeSelectionDetails.hasChangesFromInitial;
331
+ : changeSelectionDetails.hasChangesFromInitial) || Boolean(appliedPromoCode);
332
332
 
333
333
  const {
334
334
  providerTotalsPreview,
@@ -8,8 +8,8 @@ import {
8
8
  } from 'react';
9
9
  import {
10
10
  quoteChangeBooking,
11
- quoteAdminChangeBookingV2,
12
11
  type AdminAmendmentRefundDisposition,
12
+ type AdminPromoApplicationScope,
13
13
  type AdminFeAuthoritativeReceipt,
14
14
  type Availability,
15
15
  type ItineraryDisplayStep,
@@ -27,7 +27,14 @@ import {
27
27
  } from './change-booking-flow-helpers';
28
28
  import type { Currency } from './CurrencySwitcher';
29
29
  import type { AdminChangeLatestQuote } from './useAdminChangeCheckoutController';
30
- import { buildAdminChangeQuoteRequestKey } from './admin-change-quote-request-key';
30
+ import {
31
+ buildAdminChangeQuoteRequestKey,
32
+ canReuseCompletedAdminChangeQuote,
33
+ } from './admin-change-quote-request-key';
34
+ import {
35
+ buildAdminChangeV2QuoteRequest,
36
+ quoteAdminChangeV2ForUi,
37
+ } from './admin-change-v2-quote-request';
31
38
  import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
32
39
 
33
40
  type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
@@ -60,6 +67,8 @@ export interface UseAdminChangeQuotePreviewParams {
60
67
  quantities: Record<string, number>;
61
68
  addOnSelections: AddOnSelection[];
62
69
  cancellationPolicyId: string | null;
70
+ appliedPromoCode: string | null;
71
+ promoApplicationScope: AdminPromoApplicationScope;
63
72
  adminCustomReceiptLines: AdminCustomReceiptLine[];
64
73
  adminStructuredAdjustments: NonNullable<ProviderDashboardChangeBookingPayload['structuredAdjustments']>;
65
74
  refundDispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>;
@@ -119,6 +128,8 @@ export function useAdminChangeQuotePreview({
119
128
  quantities,
120
129
  addOnSelections,
121
130
  cancellationPolicyId,
131
+ appliedPromoCode,
132
+ promoApplicationScope,
122
133
  adminCustomReceiptLines,
123
134
  adminStructuredAdjustments,
124
135
  refundDispositionsByOperationId,
@@ -167,6 +178,7 @@ export function useAdminChangeQuotePreview({
167
178
  latestChangeQuote?.canProceed === false &&
168
179
  (latestChangeQuote.refundDecisionOperations?.length ?? 0) === 0;
169
180
  const refundDecisionRequired = (latestChangeQuote?.refundDecisionOperations?.length ?? 0) > 0;
181
+ const hasPublishedChangeQuote = latestChangeQuote !== null;
170
182
  const changeQuoteInputsKey = useMemo(() => buildAdminChangeQuoteRequestKey({
171
183
  bookingReference: initialValues?.bookingReference,
172
184
  lastName,
@@ -178,6 +190,8 @@ export function useAdminChangeQuotePreview({
178
190
  addOnSelections,
179
191
  adminCustomReceiptLines,
180
192
  cancellationPolicyId,
193
+ promoCode: appliedPromoCode,
194
+ promoApplicationScope,
181
195
  structuredAdjustments: adminStructuredAdjustments,
182
196
  refundDispositionsByOperationId,
183
197
  useAdminFeAuthoritativeQuote,
@@ -192,6 +206,8 @@ export function useAdminChangeQuotePreview({
192
206
  addOnSelections,
193
207
  adminCustomReceiptLines,
194
208
  cancellationPolicyId,
209
+ appliedPromoCode,
210
+ promoApplicationScope,
195
211
  adminStructuredAdjustments,
196
212
  refundDispositionsByOperationId,
197
213
  useAdminFeAuthoritativeQuote,
@@ -208,6 +224,14 @@ export function useAdminChangeQuotePreview({
208
224
  const changeCheckoutButtonLabel = (() => {
209
225
  if (!hasEffectiveChangeSelection) return undefined;
210
226
  if (isProviderDashboardChange) {
227
+ if (changeQuoteLoading || (!latestChangeQuote && !changeQuoteFetchError)) {
228
+ const tr = t('booking.updatingPrice');
229
+ return tr !== 'booking.updatingPrice' ? tr : 'Updating price\u2026';
230
+ }
231
+ if (changeQuoteFetchError) {
232
+ const tr = t('booking.changeBookingRetry');
233
+ return tr !== 'booking.changeBookingRetry' ? tr : 'Change booking (retry)';
234
+ }
211
235
  if (isChangeQuoteBlocked) {
212
236
  const tr = t('booking.changeBookingNotAvailable');
213
237
  return tr !== 'booking.changeBookingNotAvailable' ? tr : 'Change not available';
@@ -215,8 +239,8 @@ export function useAdminChangeQuotePreview({
215
239
  // Prefer authoritative V2 signed due (charge positive / refund negative). Do not use the
216
240
  // FE cart estimate once a server quote exists — receipt total deltas can disagree with
217
241
  // floored/settlement amounts (DEFECT-PV2-018 follow-up).
218
- const serverDue = latestChangeQuote?.serverPreview?.amountDue;
219
- const due = Math.round((serverDue ?? changeFlowClientEstimateDue) * 100) / 100;
242
+ const serverDue = latestChangeQuote?.serverPreview?.amountDue ?? latestChangeQuote?.priceDiff ?? 0;
243
+ const due = Math.round(serverDue * 100) / 100;
220
244
  return due > 0
221
245
  ? `Change booking (${formatCurrencyAmount(due, currency, locale as 'en' | 'fr')})`
222
246
  : due < 0
@@ -398,11 +422,15 @@ export function useAdminChangeQuotePreview({
398
422
  return;
399
423
  }
400
424
 
401
- if (
402
- changeQuoteInputsKey &&
403
- (inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey ||
404
- lastCompletedQuoteInputsKeyRef.current === changeQuoteInputsKey)
405
- ) {
425
+ if (changeQuoteInputsKey && inFlightQuoteInputsKeyRef.current === changeQuoteInputsKey) {
426
+ return;
427
+ }
428
+
429
+ if (canReuseCompletedAdminChangeQuote(
430
+ changeQuoteInputsKey,
431
+ lastCompletedQuoteInputsKeyRef.current,
432
+ hasPublishedChangeQuote,
433
+ )) {
406
434
  setChangeQuoteLoading(false);
407
435
  return;
408
436
  }
@@ -429,44 +457,44 @@ export function useAdminChangeQuotePreview({
429
457
  .filter(([, count]) => count > 0)
430
458
  .map(([category, count]) => ({ category, count }));
431
459
  try {
432
- const quoteRequestBase = {
460
+ const quoteRequestBase = buildAdminChangeV2QuoteRequest({
433
461
  bookingReference: bookingReferenceForQuote,
434
- lastName: lastName.trim(),
435
- newProductId: optionId,
436
- newParentProductId: product.productId,
437
- newDateTime: selectedAvailability.dateTime,
438
- newAvailabilityId: selectedAvailability.availabilityId || null,
439
- newPickupLocationId: pickupLocationId || null,
440
- newReturnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
441
- newPassengerCounts: bookingItems,
442
- // Admin owns the complete target selection. [] intentionally clears all add-ons.
443
- newAddOnSelections: addOnSelections,
444
- cancellationPolicyId,
462
+ lastName,
463
+ parentProductId: product.productId,
464
+ optionId,
465
+ selectedAvailability,
466
+ pickupLocationId,
467
+ returnAvailabilityId: selectedReturnOption?.returnAvailabilityId ?? null,
468
+ bookingItems,
469
+ addOnSelections,
470
+ cancellationPolicyId: cancellationPolicyId ?? null,
471
+ promoCode: appliedPromoCode,
472
+ promoApplicationScope,
445
473
  structuredAdjustments: adminStructuredAdjustments,
446
- ...(Object.keys(refundDispositionsByOperationId).length > 0
447
- ? { refundDispositionsByOperationId }
448
- : {}),
449
- clientProposedTotal: changeFlowNewBookingTotal,
450
- capacitySeatCredit: {
451
- enabled: true,
452
- previousPassengerCount: changeFlowInitialTicketCount,
453
- previousAvailabilityId: initialValues.availabilityId ?? null,
454
- previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
455
- },
456
- };
457
- const quote = useAdminFeAuthoritativeQuote
458
- ? await quoteAdminChangeBookingV2({
474
+ refundDispositionsByOperationId,
475
+ previousPassengerCount: changeFlowInitialTicketCount,
476
+ previousAvailabilityId: initialValues.availabilityId ?? null,
477
+ previousReturnAvailabilityId: initialValues.returnAvailabilityId ?? null,
478
+ });
479
+ const mergedQuote = useAdminFeAuthoritativeQuote
480
+ ? await quoteAdminChangeV2ForUi(
481
+ quoteRequestBase,
482
+ {
483
+ total: changeFlowNewBookingTotal,
484
+ subtotal: effectiveSubtotalForCheckout,
485
+ tax: effectiveTax,
486
+ },
487
+ currency,
488
+ )
489
+ : sliceAndMergeChangeQuoteForUi(await quoteChangeBooking({
459
490
  ...quoteRequestBase,
460
- // V2 is server-authoritative. Do not bind it to the outbound-only FE estimate.
461
- clientProposedTotal: undefined,
462
- })
463
- : await quoteChangeBooking(quoteRequestBase);
491
+ clientProposedTotal: changeFlowNewBookingTotal,
492
+ }), {
493
+ total: changeFlowNewBookingTotal,
494
+ subtotal: effectiveSubtotalForCheckout,
495
+ tax: effectiveTax,
496
+ }, currency).mergedQuote;
464
497
  if (seq !== changeQuoteRequestSeq.current) return;
465
- const mergedQuote = sliceAndMergeChangeQuoteForUi(quote, {
466
- total: changeFlowNewBookingTotal,
467
- subtotal: effectiveSubtotalForCheckout,
468
- tax: effectiveTax,
469
- }, currency).mergedQuote;
470
498
  // Commit the completed identity before publishing its result. This keeps a
471
499
  // render caused by setLatestChangeQuote from being interpreted as a new request.
472
500
  lastCompletedQuoteInputsKeyRef.current = changeQuoteInputsKey;
@@ -497,6 +525,7 @@ export function useAdminChangeQuotePreview({
497
525
  hasChangeSelection,
498
526
  missingRequiredReturnSelection,
499
527
  changeQuoteInputsKey,
528
+ hasPublishedChangeQuote,
500
529
  ]);
501
530
 
502
531
  return {