@ticketboothapp/booking 1.2.122 → 1.2.124

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 (112) hide show
  1. package/CHANGE_BOOKING_BE_HANDOFF.md +15 -4
  2. package/package.json +1 -1
  3. package/src/components/booking/AdminChangeBookingContent.tsx +276 -0
  4. package/src/components/booking/AdminChangeBookingFlow.tsx +647 -6157
  5. package/src/components/booking/AdminChangeCheckoutDialogs.tsx +105 -0
  6. package/src/components/booking/AdminChangeCheckoutPanel.tsx +307 -0
  7. package/src/components/booking/AdminChangePricingPanel.tsx +244 -0
  8. package/src/components/booking/AdminChangeProductSwitcher.tsx +50 -0
  9. package/src/components/booking/AdminChangeReceiptComparison.tsx +167 -0
  10. package/src/components/booking/ChangeBookingCalendarPanel.tsx +126 -0
  11. package/src/components/booking/ChangeBookingCheckoutPanel.tsx +204 -0
  12. package/src/components/booking/ChangeBookingFlow.tsx +576 -4648
  13. package/src/components/booking/ChangeBookingItineraryPanel.tsx +115 -0
  14. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +57 -0
  15. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +157 -0
  16. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +102 -0
  17. package/src/components/booking/CheckoutModal.tsx +68 -49
  18. package/src/components/booking/NewBookingFlow.tsx +485 -3505
  19. package/src/components/booking/PriceBreakdown.tsx +17 -3
  20. package/src/components/booking/PriceSummary.tsx +21 -3
  21. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +102 -0
  22. package/src/components/booking/PrivateShuttleBookingFlow.tsx +407 -2788
  23. package/src/components/booking/PrivateShuttleCheckoutDialogs.tsx +106 -0
  24. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +333 -0
  25. package/src/components/booking/PrivateShuttleItinerarySection.tsx +95 -0
  26. package/src/components/booking/PrivateShuttleMediaIntro.tsx +62 -0
  27. package/src/components/booking/PrivateShuttleOptionSection.tsx +81 -0
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +148 -0
  29. package/src/components/booking/PrivateShuttlePickupSection.tsx +91 -0
  30. package/src/components/booking/PrivateShuttlePreferencesSection.tsx +75 -0
  31. package/src/components/booking/PrivateShuttleStartTimeSection.tsx +88 -0
  32. package/src/components/booking/StandardBookingCheckoutDialogs.tsx +106 -0
  33. package/src/components/booking/StandardBookingCheckoutSection.tsx +185 -0
  34. package/src/components/booking/StandardBookingItineraryPanel.tsx +125 -0
  35. package/src/components/booking/StandardBookingMediaIntro.tsx +69 -0
  36. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +236 -0
  37. package/src/components/booking/admin-change-customer-quote-runner.ts +119 -0
  38. package/src/components/booking/admin-change-flow-state-helpers.ts +124 -0
  39. package/src/components/booking/admin-change-payment-choice-runner.ts +172 -0
  40. package/src/components/booking/admin-change-provider-payload.ts +101 -0
  41. package/src/components/booking/change-booking-checkout-builders.ts +185 -0
  42. package/src/components/booking/change-booking-checkout-quote-runner.ts +207 -0
  43. package/src/components/booking/change-booking-flow-helpers.ts +706 -0
  44. package/src/components/booking/change-booking-payment-modal-builders.ts +151 -0
  45. package/src/components/booking/change-booking-quote-guards.ts +223 -0
  46. package/src/components/booking/change-booking-quote-state.ts +64 -0
  47. package/src/components/booking/pricing-v2-checkout-summary.ts +144 -0
  48. package/src/components/booking/private-shuttle-availability.ts +111 -0
  49. package/src/components/booking/private-shuttle-checkout-builders.ts +264 -0
  50. package/src/components/booking/private-shuttle-checkout-controller.ts +744 -0
  51. package/src/components/booking/private-shuttle-payment-choice-runner.ts +271 -0
  52. package/src/components/booking/private-shuttle-provider-change-runner.ts +73 -0
  53. package/src/components/booking/private-shuttle-reservation-runner.ts +260 -0
  54. package/src/components/booking/standard-booking-availability.ts +305 -0
  55. package/src/components/booking/standard-booking-checkout-builders.ts +289 -0
  56. package/src/components/booking/standard-booking-checkout-controller.ts +659 -0
  57. package/src/components/booking/standard-booking-payment-choice-runner.ts +274 -0
  58. package/src/components/booking/standard-booking-reservation-runner.ts +229 -0
  59. package/src/components/booking/use-private-shuttle-availability.ts +602 -0
  60. package/src/components/booking/use-standard-booking-availability.ts +806 -0
  61. package/src/components/booking/useActiveProductOptions.ts +26 -0
  62. package/src/components/booking/useAdminChangeCheckoutController.ts +806 -0
  63. package/src/components/booking/useAdminChangeOrderSummaryModel.ts +246 -0
  64. package/src/components/booking/useAdminChangePriceSummary.ts +416 -0
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +178 -0
  66. package/src/components/booking/useAdminChangeProductReset.ts +60 -0
  67. package/src/components/booking/useAdminChangeProducts.ts +102 -0
  68. package/src/components/booking/useAdminChangePromoDiscount.ts +235 -0
  69. package/src/components/booking/useAdminChangeProtectedPricing.ts +280 -0
  70. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +437 -0
  71. package/src/components/booking/useAdminChangeQuotePreview.ts +506 -0
  72. package/src/components/booking/useAdminChangeReceiptDerivation.ts +203 -0
  73. package/src/components/booking/useAdminCustomReceiptLines.ts +57 -0
  74. package/src/components/booking/useAdminProviderPricingAdjustments.ts +146 -0
  75. package/src/components/booking/useBookingAvailabilityAddOns.ts +54 -0
  76. package/src/components/booking/useBookingPromoAndQuantityController.ts +163 -0
  77. package/src/components/booking/useBookingQuantityCapTrim.ts +56 -0
  78. package/src/components/booking/useBookingViewItemAnalytics.ts +30 -0
  79. package/src/components/booking/useChangeBookingAddOnFloorController.ts +109 -0
  80. package/src/components/booking/useChangeBookingAutoSelections.ts +148 -0
  81. package/src/components/booking/useChangeBookingAvailabilities.ts +692 -0
  82. package/src/components/booking/useChangeBookingCheckoutController.ts +461 -0
  83. package/src/components/booking/useChangeBookingInitialHydration.ts +204 -0
  84. package/src/components/booking/useChangeBookingItineraryUiState.ts +133 -0
  85. package/src/components/booking/useChangeBookingPickupItinerary.ts +472 -0
  86. package/src/components/booking/useChangeBookingPriceSummary.ts +199 -0
  87. package/src/components/booking/useChangeBookingPromoDiscount.ts +218 -0
  88. package/src/components/booking/useChangeBookingProtectedPricing.ts +256 -0
  89. package/src/components/booking/useChangeBookingQuotePreview.ts +442 -0
  90. package/src/components/booking/useChangeBookingReceiptDerivation.ts +128 -0
  91. package/src/components/booking/useChangeBookingReceiptFloorController.ts +449 -0
  92. package/src/components/booking/useChangeBookingSelection.ts +362 -0
  93. package/src/components/booking/useChangeBookingSelectionDetails.ts +200 -0
  94. package/src/components/booking/useChangeBookingSummaryLines.ts +169 -0
  95. package/src/components/booking/usePrivateShuttlePriceSummary.ts +513 -0
  96. package/src/components/booking/usePrivateShuttlePromoController.ts +143 -0
  97. package/src/components/booking/usePrivateShuttlePromoDiscount.ts +124 -0
  98. package/src/components/booking/useSeedPromoCodeInput.ts +13 -0
  99. package/src/components/booking/useStandardBookingAutoSelections.ts +224 -0
  100. package/src/components/booking/useStandardBookingPickupItinerary.ts +473 -0
  101. package/src/components/booking/useStandardBookingPriceSummary.ts +438 -0
  102. package/src/components/booking/useStandardBookingPromoController.ts +258 -0
  103. package/src/components/booking/useStandardBookingPromoDiscount.ts +203 -0
  104. package/src/data/products-config.json +1 -1
  105. package/src/lib/booking/change-booking-server-preview.ts +134 -20
  106. package/src/lib/booking/change-flow-pricing.ts +31 -11
  107. package/src/lib/booking/checkout-breakdown.ts +66 -1
  108. package/src/lib/booking/pricing-config.ts +10 -0
  109. package/src/lib/booking/source-metadata.ts +58 -0
  110. package/src/lib/booking-api.ts +154 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -0,0 +1,203 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ getPromoDiscount,
4
+ type Availability,
5
+ type PricingConfig,
6
+ } from '../../lib/booking-api';
7
+ import type {
8
+ OrderSummaryFeeLine,
9
+ OrderSummaryTicketLine,
10
+ } from '../../lib/booking/pricing';
11
+ import type { Currency } from './CurrencySwitcher';
12
+ import { getAvailabilityOptionId } from './standard-booking-availability';
13
+
14
+ interface UseStandardBookingPromoDiscountParams {
15
+ appliedPromoCode: string | null;
16
+ selectedAvailability: Availability | null;
17
+ totalQuantity: number;
18
+ productCompanyId?: string | null;
19
+ envCompanyId?: string | null;
20
+ productId: string;
21
+ currency: Currency;
22
+ quantities: Record<string, number>;
23
+ ticketLineItems: OrderSummaryTicketLine[];
24
+ feeLineItems: OrderSummaryFeeLine[];
25
+ effectiveSubtotal: number;
26
+ isTaxIncludedInPrice: boolean;
27
+ pricingConfig: PricingConfig | null;
28
+ }
29
+
30
+ export function useStandardBookingPromoDiscount({
31
+ appliedPromoCode,
32
+ selectedAvailability,
33
+ totalQuantity,
34
+ productCompanyId,
35
+ envCompanyId,
36
+ productId,
37
+ currency,
38
+ quantities,
39
+ ticketLineItems,
40
+ feeLineItems,
41
+ effectiveSubtotal,
42
+ isTaxIncludedInPrice,
43
+ pricingConfig,
44
+ }: UseStandardBookingPromoDiscountParams) {
45
+ const quantitiesSignature = useMemo(
46
+ () =>
47
+ Object.entries(quantities)
48
+ .filter(([, n]) => (n ?? 0) > 0)
49
+ .sort(([a], [b]) => a.localeCompare(b))
50
+ .map(([c, n]) => `${c}:${n}`)
51
+ .join('|'),
52
+ [quantities],
53
+ );
54
+
55
+ const [promoDiscountAmount, setPromoDiscountAmount] = useState(0);
56
+ const [isGiftCard, setIsGiftCard] = useState(false);
57
+ const [isVoucher, setIsVoucher] = useState(false);
58
+ const promoDiscountFetchGenerationRef = useRef(0);
59
+ const promoDiscountParamsRef = useRef({
60
+ selectedAvailability: null as Availability | null,
61
+ ticketLineItems: [] as Array<{ category: string; qty: number; itemTotal: number }>,
62
+ effectiveSubtotal: 0,
63
+ displayedProductFeeTotal: 0,
64
+ appliedPromoCode: null as string | null,
65
+ });
66
+
67
+ const selectedAvailabilityOptionId = useMemo(
68
+ () => (selectedAvailability ? getAvailabilityOptionId(selectedAvailability) : ''),
69
+ [selectedAvailability],
70
+ );
71
+
72
+ const promoDiscountFetchKey = useMemo(() => {
73
+ if (!appliedPromoCode || !selectedAvailability || totalQuantity === 0) return '';
74
+ const companyId = productCompanyId ?? envCompanyId;
75
+ if (!companyId) return '';
76
+ if (!selectedAvailabilityOptionId || !quantitiesSignature) return '';
77
+ return [
78
+ appliedPromoCode,
79
+ companyId,
80
+ productId,
81
+ selectedAvailabilityOptionId,
82
+ selectedAvailability.dateTime,
83
+ selectedAvailability.availabilityId ?? '',
84
+ currency,
85
+ quantitiesSignature,
86
+ ticketLineItems
87
+ .map((line) => `${line.category}:${line.qty}:${Math.round(line.itemTotal * 100)}`)
88
+ .sort()
89
+ .join('|'),
90
+ String(Math.round(feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0) * 100)),
91
+ String(Math.round(effectiveSubtotal * 100)),
92
+ ].join('::');
93
+ }, [
94
+ appliedPromoCode,
95
+ selectedAvailability,
96
+ selectedAvailabilityOptionId,
97
+ totalQuantity,
98
+ productCompanyId,
99
+ envCompanyId,
100
+ productId,
101
+ currency,
102
+ quantitiesSignature,
103
+ ticketLineItems,
104
+ feeLineItems,
105
+ effectiveSubtotal,
106
+ ]);
107
+
108
+ promoDiscountParamsRef.current = {
109
+ selectedAvailability,
110
+ ticketLineItems: ticketLineItems.map((line) => ({
111
+ category: line.category,
112
+ qty: line.qty,
113
+ itemTotal: line.itemTotal,
114
+ })),
115
+ effectiveSubtotal,
116
+ displayedProductFeeTotal: feeLineItems.reduce((sum, fee) => sum + fee.totalAmount, 0),
117
+ appliedPromoCode,
118
+ };
119
+
120
+ useEffect(() => {
121
+ if (!promoDiscountFetchKey) {
122
+ setPromoDiscountAmount(0);
123
+ setIsGiftCard(false);
124
+ setIsVoucher(false);
125
+ return;
126
+ }
127
+
128
+ let cancelled = false;
129
+ const debounceMs = 120;
130
+ const timer = window.setTimeout(() => {
131
+ if (cancelled) return;
132
+ const {
133
+ selectedAvailability: sel,
134
+ ticketLineItems: lines,
135
+ effectiveSubtotal: sub,
136
+ displayedProductFeeTotal,
137
+ appliedPromoCode: code,
138
+ } = promoDiscountParamsRef.current;
139
+ if (!code || !sel) return;
140
+ const companyId = productCompanyId ?? envCompanyId;
141
+ const optionId = getAvailabilityOptionId(sel);
142
+ if (!companyId || !optionId) return;
143
+ const items = lines.map((line) => ({ category: line.category, qty: line.qty }));
144
+ if (items.length === 0) return;
145
+
146
+ const generation = ++promoDiscountFetchGenerationRef.current;
147
+ getPromoDiscount(
148
+ code,
149
+ companyId,
150
+ productId,
151
+ optionId,
152
+ currency,
153
+ items,
154
+ sel.dateTime,
155
+ sub,
156
+ {
157
+ availabilityCount: sel.vacancies,
158
+ totalCapacity: sel.totalCapacity,
159
+ displayedTicketLines: lines,
160
+ displayedProductFeeTotal,
161
+ },
162
+ )
163
+ .then((res) => {
164
+ if (cancelled) return;
165
+ if (generation !== promoDiscountFetchGenerationRef.current) return;
166
+ setPromoDiscountAmount(res.discount ?? 0);
167
+ setIsGiftCard(res.isGiftCard ?? false);
168
+ setIsVoucher(res.isVoucher ?? false);
169
+ })
170
+ .catch(() => {
171
+ if (cancelled) return;
172
+ if (generation !== promoDiscountFetchGenerationRef.current) return;
173
+ setPromoDiscountAmount(0);
174
+ setIsGiftCard(false);
175
+ setIsVoucher(false);
176
+ });
177
+ }, debounceMs);
178
+
179
+ return () => {
180
+ cancelled = true;
181
+ window.clearTimeout(timer);
182
+ };
183
+ }, [promoDiscountFetchKey, productCompanyId, envCompanyId, productId, currency]);
184
+
185
+ // Percentage/fixed promos: tax on discounted amount (promo before GST per CRA guidance).
186
+ // Vouchers and gift-card comps: tax on full subtotal (gift-card comp reduces amount due after tax).
187
+ const effectivePromoDiscountAmount = promoDiscountAmount > 0 ? promoDiscountAmount : 0;
188
+ const taxOnSubtotal = isTaxIncludedInPrice ? 0 : effectiveSubtotal * (pricingConfig?.taxRate ?? 0);
189
+ const effectiveTax =
190
+ effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
191
+ ? (effectiveSubtotal - effectivePromoDiscountAmount) * (pricingConfig?.taxRate ?? 0)
192
+ : taxOnSubtotal;
193
+ const totalPrice = effectiveSubtotal + effectiveTax - effectivePromoDiscountAmount;
194
+
195
+ return {
196
+ promoDiscountAmount,
197
+ isGiftCard,
198
+ isVoucher,
199
+ effectivePromoDiscountAmount,
200
+ effectiveTax,
201
+ totalPrice,
202
+ };
203
+ }
@@ -113,7 +113,7 @@
113
113
  "productId": "p_urmbvZepRegd",
114
114
  "optionIds": ["po_uO9Ra6NHzwof", "po_44EQlZn24gXg", "po_vuzqpvGk6wVS", "po_nIToR7Qwau3E"],
115
115
  "productType": "PRIVATE_SHUTTLE",
116
- "bookingCutoffMinutes": 2880,
116
+ "bookingCutoffMinutes": 720,
117
117
  "display": {
118
118
  "path": "/private-shuttle",
119
119
  "slug": "private-tour",
@@ -5,6 +5,7 @@
5
5
  * See `CHANGE_BOOKING_BE_HANDOFF.md` in this package for fields the API should populate.
6
6
  */
7
7
  import type { ChangeBookingQuoteResponse } from '../booking-api';
8
+ import type { PricingV2QuoteLineSnapshot } from '../booking-api';
8
9
  import type { PriceSummaryLine } from '../../components/booking/PriceSummary';
9
10
  import { roundMoney, serverTotalsFromChangeQuoteResponse } from './change-flow-pricing';
10
11
 
@@ -26,27 +27,123 @@ export interface ChangeBookingServerPreview {
26
27
  ticketUnitPriceByCategory?: Record<string, number>;
27
28
  cancellationPolicyFeeByPolicyId?: Record<string, number>;
28
29
  returnOptionPriceByReturnAvailabilityId?: Record<string, number>;
30
+ paymentCreditTotal?: number;
31
+ quoteId?: string | null;
32
+ quoteStatus?: string | null;
33
+ quoteExpiresAt?: string | null;
34
+ pricingVersion?: string | null;
29
35
  }
30
36
 
31
- function lineItemsForSummary(quote: ChangeBookingQuoteResponse): NonNullable<
32
- ChangeBookingQuoteResponse['newReceipt']
33
- >['lineItems'] {
34
- return quote.newReceipt?.lineItems ?? quote.proposed?.lineItems ?? quote.original?.lineItems ?? undefined;
37
+ type QuoteSummaryInputLine =
38
+ | PricingV2QuoteLineSnapshot
39
+ | {
40
+ label?: string;
41
+ amount?: number;
42
+ type?: string;
43
+ quantity?: number;
44
+ };
45
+
46
+ function lineItemsForSummary(quote: ChangeBookingQuoteResponse): QuoteSummaryInputLine[] | undefined {
47
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
48
+ return pricingQuote?.lines ?? quote.newReceipt?.lineItems ?? quote.proposed?.lineItems ?? quote.original?.lineItems ?? undefined;
49
+ }
50
+
51
+ function normalizedUpper(value: unknown): string {
52
+ return typeof value === 'string' ? value.trim().toUpperCase() : '';
53
+ }
54
+
55
+ function isPricingV2Line(line: QuoteSummaryInputLine): line is PricingV2QuoteLineSnapshot {
56
+ return (
57
+ 'billable' in line ||
58
+ 'discountBehavior' in line ||
59
+ 'taxBehavior' in line ||
60
+ 'source' in line ||
61
+ 'unitAmount' in line
62
+ );
63
+ }
64
+
65
+ function isReductionLine(line: QuoteSummaryInputLine): boolean {
66
+ const type = normalizedUpper(line.type);
67
+ const source = isPricingV2Line(line) ? normalizedUpper(line.source) : '';
68
+ const discountBehavior = isPricingV2Line(line) ? normalizedUpper(line.discountBehavior) : '';
69
+ return (
70
+ type === 'PROMO_CODE' ||
71
+ type === 'DISCOUNT' ||
72
+ type === 'GIFT_CARD' ||
73
+ type === 'VOUCHER' ||
74
+ type === 'PAYMENT_CREDIT' ||
75
+ source === 'PROMO' ||
76
+ source === 'GIFT_CARD' ||
77
+ source === 'VOUCHER' ||
78
+ discountBehavior === 'PAYMENT_CREDIT' ||
79
+ discountBehavior.endsWith('_DISCOUNT')
80
+ );
81
+ }
82
+
83
+ function summaryTypeForLine(line: QuoteSummaryInputLine): string {
84
+ const type = normalizedUpper(line.type);
85
+ if (isPricingV2Line(line)) {
86
+ const taxBehavior = normalizedUpper(line.taxBehavior);
87
+ const discountBehavior = normalizedUpper(line.discountBehavior);
88
+ const source = normalizedUpper(line.source);
89
+ if (taxBehavior === 'TAX' || type === 'TAX' || source === 'TAX') return 'TAX';
90
+ if (discountBehavior === 'PAYMENT_CREDIT') return source === 'GIFT_CARD' ? 'GIFT_CARD' : 'PAYMENT_CREDIT';
91
+ if (source === 'GIFT_CARD') return 'GIFT_CARD';
92
+ if (source === 'VOUCHER') return 'VOUCHER';
93
+ if (source === 'PROMO' || discountBehavior.endsWith('_DISCOUNT')) return type || 'DISCOUNT';
94
+ }
95
+ if (type === 'RETURNOPTION') return 'return';
96
+ if (type === 'RETURN_OPTION') return 'return';
97
+ return type || 'FEE';
98
+ }
99
+
100
+ function amountForDisplay(line: QuoteSummaryInputLine): number {
101
+ const amount = Number(line.amount) || 0;
102
+ return isReductionLine(line) && amount > 0 ? -amount : amount;
103
+ }
104
+
105
+ function hasPaymentCreditLine(lines: PriceSummaryLine[]): boolean {
106
+ return lines.some((line) => {
107
+ if (line.kind !== 'line') return false;
108
+ const type = normalizedUpper(line.type);
109
+ const label = line.label.toLowerCase();
110
+ return (
111
+ type === 'PAYMENT_CREDIT' ||
112
+ type === 'GIFT_CARD' ||
113
+ label.includes('payment credit') ||
114
+ label.includes('comp credit') ||
115
+ label.includes('gift card')
116
+ );
117
+ });
118
+ }
119
+
120
+ function withPaymentCreditSummaryLine(
121
+ lines: PriceSummaryLine[],
122
+ paymentCreditTotal: number | null | undefined,
123
+ ): PriceSummaryLine[] {
124
+ if (paymentCreditTotal == null || !Number.isFinite(paymentCreditTotal) || paymentCreditTotal <= 0) return lines;
125
+ if (hasPaymentCreditLine(lines)) return lines;
126
+ return [
127
+ ...lines,
128
+ {
129
+ kind: 'line',
130
+ label: 'Gift card comp',
131
+ amount: -roundMoney(paymentCreditTotal),
132
+ type: 'PAYMENT_CREDIT',
133
+ },
134
+ ];
35
135
  }
36
136
 
37
137
  /** Map receipt-style quote line items to checkout {@link PriceSummaryLine} rows (shared with price-check drift UI). */
38
138
  export function mapQuoteLineItemsToPriceSummaryLines(
39
- items:
40
- | NonNullable<NonNullable<ChangeBookingQuoteResponse['newReceipt']>['lineItems']>
41
- | Array<{ label?: string; amount?: number; type?: string; quantity?: number }>
42
- | undefined
43
- | null,
139
+ items: QuoteSummaryInputLine[] | undefined | null,
44
140
  ): PriceSummaryLine[] {
45
141
  if (!items?.length) return [];
46
142
  const out: PriceSummaryLine[] = [];
47
143
  for (const item of items) {
48
- const amount = Number(item.amount) || 0;
49
- const type = (item.type ?? '').toUpperCase();
144
+ if (isPricingV2Line(item) && item.billable === false) continue;
145
+ const amount = amountForDisplay(item);
146
+ const type = summaryTypeForLine(item);
50
147
  const qty = Math.max(0, Number(item.quantity) || 0);
51
148
  const label = (item.label ?? '').trim();
52
149
  if (
@@ -90,19 +187,29 @@ export function mapQuoteLineItemsToPriceSummaryLines(
90
187
  }
91
188
 
92
189
  /** Align with checkout drift keys (`ChangeBookingFlow` uses `type: 'return'`). */
93
- const summaryType =
94
- type === 'RETURN_OPTION' || type === 'RETURNOPTION' ? 'return' : (item.type ?? 'fee');
95
190
  out.push({
96
191
  kind: 'line',
97
192
  label: label || type || 'Line',
98
193
  amount,
99
- type: summaryType,
194
+ type,
100
195
  quantity: qty > 0 ? qty : null,
101
196
  });
102
197
  }
103
198
  return out;
104
199
  }
105
200
 
201
+ function signedAmountDueFromQuote(quote: ChangeBookingQuoteResponse): number {
202
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
203
+ if (quote.balanceDeltaMajorUnits != null) return quote.balanceDeltaMajorUnits;
204
+ if (quote.balanceDelta != null) return quote.balanceDelta;
205
+ if (pricingQuote?.balanceDelta != null) return pricingQuote.balanceDelta;
206
+ const charge = pricingQuote?.amountToCharge ?? quote.amountToCharge;
207
+ const refund = pricingQuote?.refundCandidate ?? quote.refundCandidate;
208
+ if (charge != null || refund != null) return (charge ?? 0) - (refund ?? 0);
209
+ if (quote.amountDueCents != null) return quote.amountDueCents / 100;
210
+ return quote.priceDiff ?? 0;
211
+ }
212
+
106
213
  /**
107
214
  * Single builder: quote JSON → preview consumed by ChangeBookingFlow (no parallel FE “display math”).
108
215
  */
@@ -114,16 +221,18 @@ export function buildChangeBookingServerPreview(
114
221
  const totals = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
115
222
  if (!totals) return null;
116
223
 
117
- const currency = (quote.currency ?? currencyFallback).trim() || currencyFallback;
118
- const amountDue =
119
- quote.amountDueCents != null ? quote.amountDueCents / 100 : quote.priceDiff ?? 0;
224
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
225
+ const currency = (pricingQuote?.currency ?? quote.currency ?? currencyFallback).trim() || currencyFallback;
226
+ const amountDue = signedAmountDueFromQuote(quote);
227
+ const paymentCreditTotal = pricingQuote?.paymentCreditTotal ?? quote.paymentCreditTotal;
120
228
 
121
229
  const rawLines = lineItemsForSummary(quote);
122
230
  const priceSummaryLines =
123
- rawLines && rawLines.length > 0 ? mapQuoteLineItemsToPriceSummaryLines(rawLines) : [];
231
+ rawLines && rawLines.length > 0
232
+ ? withPaymentCreditSummaryLine(mapQuoteLineItemsToPriceSummaryLines(rawLines), paymentCreditTotal)
233
+ : withPaymentCreditSummaryLine([], paymentCreditTotal);
124
234
 
125
- /** FE assumes the change-quote contract is implemented; no `totals_only` gating in UI. */
126
- const completeness: ChangeBookingPreviewCompleteness = 'full';
235
+ const completeness: ChangeBookingPreviewCompleteness = priceSummaryLines.length > 0 ? 'full' : 'totals_only';
127
236
 
128
237
  return {
129
238
  currency,
@@ -136,5 +245,10 @@ export function buildChangeBookingServerPreview(
136
245
  ticketUnitPriceByCategory: quote.ticketUnitPriceByCategory,
137
246
  cancellationPolicyFeeByPolicyId: quote.cancellationPolicyFeeByPolicyId,
138
247
  returnOptionPriceByReturnAvailabilityId: quote.returnOptionPriceByReturnAvailabilityId,
248
+ paymentCreditTotal,
249
+ quoteId: pricingQuote?.quoteId ?? quote.pricingQuoteId ?? null,
250
+ quoteStatus: pricingQuote?.status ?? null,
251
+ quoteExpiresAt: pricingQuote?.expiresAt ?? quote.expiresAt ?? null,
252
+ pricingVersion: pricingQuote?.pricingVersion ?? null,
139
253
  };
140
254
  }
@@ -196,19 +196,22 @@ export function changeFlowBalanceVsOriginal(input: {
196
196
  * `fallbackCart` only when the API omits breakdown (scaled to match server total when needed).
197
197
  */
198
198
  export function serverTotalsFromChangeQuoteResponse(
199
- quote: Pick<ChangeBookingQuoteResponse, 'newReceipt' | 'proposed' | 'newTotalCents'>,
199
+ quote: Pick<ChangeBookingQuoteResponse, 'newReceipt' | 'proposed' | 'newTotalCents' | 'pricingQuote' | 'quote'>,
200
200
  fallbackCart: { total: number; subtotal: number; tax: number }
201
201
  ): { total: number; subtotal: number; tax: number } | null {
202
202
  const nr = quote.newReceipt;
203
203
  const prop = quote.proposed;
204
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
204
205
  const total =
206
+ pricingQuote?.payableTotal ??
207
+ pricingQuote?.totalAmount ??
205
208
  nr?.total ??
206
209
  prop?.total ??
207
210
  (quote.newTotalCents != null ? quote.newTotalCents / 100 : undefined);
208
211
  if (total == null || !Number.isFinite(total)) return null;
209
212
 
210
- let subtotal = nr?.subtotal;
211
- let tax = nr?.tax;
213
+ let subtotal = pricingQuote?.netSubtotalBeforeTax ?? pricingQuote?.subtotal ?? nr?.subtotal;
214
+ let tax = pricingQuote?.taxAmount ?? pricingQuote?.tax ?? nr?.tax;
212
215
  if (subtotal != null && tax == null) {
213
216
  tax = Math.max(0, roundMoney(total - subtotal));
214
217
  } else if (tax != null && subtotal == null) {
@@ -246,7 +249,13 @@ export interface ChangeQuoteUiSlice {
246
249
  canProceed: boolean;
247
250
  reasonIfBlocked?: string;
248
251
  changeIntentId?: string;
252
+ quoteId?: string;
253
+ pricingQuoteInputsHash?: string;
254
+ quoteStatus?: string;
255
+ quoteExpiresAt?: string;
256
+ pricingVersion?: string;
249
257
  quotedTotal?: number;
258
+ paymentCreditTotal?: number;
250
259
  serverDisplay?: { total: number; subtotal: number; tax: number };
251
260
  }
252
261
 
@@ -256,21 +265,32 @@ export function sliceChangeQuoteForUi(
256
265
  fallbackCart: { total: number; subtotal: number; tax: number },
257
266
  currencyFallback: string
258
267
  ): ChangeQuoteUiSlice {
268
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
259
269
  /** Staff JWT on quote: signed new − previous (refund owed negative). Otherwise same as legacy nonnegative amount-due. */
260
- const priceDiff =
261
- quote.balanceDeltaMajorUnits != null
262
- ? quote.balanceDeltaMajorUnits
263
- : quote.amountDueCents != null
264
- ? quote.amountDueCents / 100
265
- : quote.priceDiff ?? 0;
270
+ const priceDiff = (() => {
271
+ if (quote.balanceDeltaMajorUnits != null) return quote.balanceDeltaMajorUnits;
272
+ if (quote.balanceDelta != null) return quote.balanceDelta;
273
+ if (pricingQuote?.balanceDelta != null) return pricingQuote.balanceDelta;
274
+ if (quote.amountToCharge != null || quote.refundCandidate != null) {
275
+ return (quote.amountToCharge ?? 0) - (quote.refundCandidate ?? 0);
276
+ }
277
+ if (quote.amountDueCents != null) return quote.amountDueCents / 100;
278
+ return quote.priceDiff ?? 0;
279
+ })();
266
280
  const serverDisplay = serverTotalsFromChangeQuoteResponse(quote, fallbackCart);
267
281
  return {
268
282
  priceDiff,
269
- currency: quote.currency ?? currencyFallback,
283
+ currency: pricingQuote?.currency ?? quote.currency ?? currencyFallback,
270
284
  canProceed: quote.canProceed !== false,
271
285
  reasonIfBlocked: quote.reasonIfBlocked,
272
286
  changeIntentId: quote.changeIntentId,
273
- quotedTotal: quote.proposed?.total ?? quote.newReceipt?.total,
287
+ quoteId: pricingQuote?.quoteId ?? quote.pricingQuoteId,
288
+ pricingQuoteInputsHash: pricingQuote?.inputsHash ?? quote.pricingQuoteInputsHash,
289
+ quoteStatus: pricingQuote?.status ?? undefined,
290
+ quoteExpiresAt: pricingQuote?.expiresAt ?? quote.expiresAt,
291
+ pricingVersion: pricingQuote?.pricingVersion ?? undefined,
292
+ quotedTotal: pricingQuote?.payableTotal ?? pricingQuote?.totalAmount ?? quote.proposed?.total ?? quote.newReceipt?.total,
293
+ paymentCreditTotal: pricingQuote?.paymentCreditTotal ?? quote.paymentCreditTotal,
274
294
  ...(serverDisplay ? { serverDisplay } : {}),
275
295
  };
276
296
  }
@@ -1,4 +1,4 @@
1
- import type { CheckoutBreakdown, CheckoutReceiptLine } from '../booking-api';
1
+ import type { CheckoutBreakdown, CheckoutReceiptLine, PricingV2QuoteSnapshot } from '../booking-api';
2
2
 
3
3
  /**
4
4
  * Round to 2 decimal places. Used so breakdown amounts and totals are stable
@@ -67,3 +67,68 @@ export function buildCheckoutBreakdown(params: BuildCheckoutBreakdownParams): Ch
67
67
  currency,
68
68
  };
69
69
  }
70
+
71
+ function pricingV2LineTypeToCheckoutType(type?: string | null): string {
72
+ const normalized = (type ?? '').trim().toUpperCase();
73
+ switch (normalized) {
74
+ case 'TICKET':
75
+ case 'FEE':
76
+ case 'TAX':
77
+ case 'PROMO_CODE':
78
+ case 'GIFT_CARD':
79
+ case 'DEAL':
80
+ case 'DISCOUNT':
81
+ case 'RETURN_OPTION':
82
+ case 'CANCELLATION_UPGRADE':
83
+ case 'ROUNDING':
84
+ case 'BOOKING_CHANGE':
85
+ return normalized;
86
+ default:
87
+ return 'FEE';
88
+ }
89
+ }
90
+
91
+ export function pricingV2QuoteTotal(quote?: PricingV2QuoteSnapshot | null): number | null {
92
+ const total = quote?.amountToCharge ?? quote?.payableTotal ?? quote?.totalAmount;
93
+ return typeof total === 'number' && Number.isFinite(total) ? round2(total) : null;
94
+ }
95
+
96
+ export function buildCheckoutBreakdownFromPricingV2Quote(
97
+ quote: PricingV2QuoteSnapshot | null | undefined,
98
+ roundingLabel: string
99
+ ): CheckoutBreakdown | null {
100
+ const totalAmount = pricingV2QuoteTotal(quote);
101
+ const currency = typeof quote?.currency === 'string' && quote.currency.trim() ? quote.currency.trim() : null;
102
+ if (totalAmount == null || currency == null) return null;
103
+
104
+ const lines =
105
+ quote?.lines
106
+ ?.filter((line) => line.billable !== false)
107
+ .flatMap((line) => {
108
+ const amount = line.amount;
109
+ if (typeof amount !== 'number' || !Number.isFinite(amount)) return [];
110
+ return [
111
+ {
112
+ label: line.label?.trim() || pricingV2LineTypeToCheckoutType(line.type),
113
+ amount,
114
+ type: pricingV2LineTypeToCheckoutType(line.type),
115
+ quantity: line.quantity ?? undefined,
116
+ },
117
+ ];
118
+ }) ?? [];
119
+
120
+ if (lines.length === 0) {
121
+ return {
122
+ lineItems: [{ label: 'Total', amount: totalAmount, type: 'FEE' }],
123
+ totalAmount,
124
+ currency,
125
+ };
126
+ }
127
+
128
+ return buildCheckoutBreakdown({
129
+ lines,
130
+ totalAmount,
131
+ currency,
132
+ roundingLabel,
133
+ });
134
+ }
@@ -0,0 +1,10 @@
1
+ import type { PricingConfig } from '../booking-api';
2
+
3
+ export function shouldReplacePricingConfig(
4
+ current: PricingConfig | null,
5
+ next: PricingConfig | null | undefined
6
+ ): next is PricingConfig {
7
+ if (!next) return false;
8
+ if (!current) return true;
9
+ return JSON.stringify(current) !== JSON.stringify(next);
10
+ }
@@ -21,6 +21,8 @@ export interface BookingSourceMetadata {
21
21
  gclid?: string;
22
22
  fbclid?: string;
23
23
  msclkid?: string;
24
+ gaClientId?: string;
25
+ gaSessionId?: string;
24
26
  partnerId?: string;
25
27
  partnerSlug?: string;
26
28
  agentId?: string;
@@ -47,6 +49,59 @@ export function isDedicatedPartnerBookingPortalHost(hostname: string): boolean {
47
49
  return h === 'booking.viaviamorainelake.com' || h === 'staging.booking.viaviamorainelake.com';
48
50
  }
49
51
 
52
+ function parseCookieMap(cookieString: string): Map<string, string> {
53
+ const decodeCookieValue = (value: string): string => {
54
+ try {
55
+ return decodeURIComponent(value);
56
+ } catch {
57
+ return value;
58
+ }
59
+ };
60
+ const map = new Map<string, string>();
61
+ cookieString
62
+ .split(';')
63
+ .map((part) => part.trim())
64
+ .filter(Boolean)
65
+ .forEach((part) => {
66
+ const eq = part.indexOf('=');
67
+ if (eq <= 0) return;
68
+ const name = part.slice(0, eq).trim();
69
+ const value = part.slice(eq + 1).trim();
70
+ if (name) map.set(name, decodeCookieValue(value));
71
+ });
72
+ return map;
73
+ }
74
+
75
+ function gaClientIdFromCookie(cookieValue: string | undefined): string | undefined {
76
+ if (!cookieValue) return undefined;
77
+ const parts = cookieValue.split('.');
78
+ if (parts.length >= 4 && /^GA\d+$/.test(parts[0])) {
79
+ return parts.slice(-2).join('.');
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function gaSessionIdFromCookie(cookieValue: string | undefined): string | undefined {
85
+ if (!cookieValue) return undefined;
86
+ const parts = cookieValue.split('.');
87
+ if (parts.length >= 3 && /^GS\d+$/.test(parts[0]) && /^\d+$/.test(parts[2])) {
88
+ return parts[2];
89
+ }
90
+ const gs2Match = cookieValue.match(/(?:^|[.$])s(\d+)(?:[$.]|$)/);
91
+ return gs2Match?.[1];
92
+ }
93
+
94
+ function readGoogleAnalyticsCookieIds(): Pick<BookingSourceMetadata, 'gaClientId' | 'gaSessionId'> {
95
+ if (typeof document === 'undefined') return {};
96
+ const cookies = parseCookieMap(document.cookie || '');
97
+ const gaClientId = gaClientIdFromCookie(cookies.get('_ga'));
98
+ const sessionCookie = Array.from(cookies.entries()).find(
99
+ ([name, value]) => name.startsWith('_ga_') && value.startsWith('GS'),
100
+ )?.[1];
101
+ const gaSessionId = gaSessionIdFromCookie(sessionCookie);
102
+ return withDefinedValues({ gaClientId, gaSessionId });
103
+ }
104
+
50
105
  /**
51
106
  * Reads the **current browser URL** (and referrer) into attribution fields. Supplements
52
107
  * `mergeBookingSourceMetadata` with `canonicalAttribution` from the route so identity does
@@ -74,6 +129,7 @@ export function buildBookingSourceMetadataFromLocation(): Partial<BookingSourceM
74
129
  const partnerSlugFromPath = partnerMatch?.[1];
75
130
  const agentIdFromQuery = params.get('agentId') || undefined;
76
131
  const agentNameFromQuery = params.get('agentName') || undefined;
132
+ const gaCookieIds = readGoogleAnalyticsCookieIds();
77
133
 
78
134
  return withDefinedValues<BookingSourceMetadata>({
79
135
  pageUrl: currentUrl.href,
@@ -88,6 +144,8 @@ export function buildBookingSourceMetadataFromLocation(): Partial<BookingSourceM
88
144
  gclid: params.get('gclid') || undefined,
89
145
  fbclid: params.get('fbclid') || undefined,
90
146
  msclkid: params.get('msclkid') || undefined,
147
+ gaClientId: gaCookieIds.gaClientId,
148
+ gaSessionId: gaCookieIds.gaSessionId,
91
149
  partnerId: partnerFromQuery,
92
150
  partnerSlug: partnerSlugFromPath,
93
151
  agentId: agentIdFromQuery,