@ticketboothapp/booking 1.2.123 → 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 +651 -6202
  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
@@ -317,6 +317,8 @@ function logBookingSourceDebug(
317
317
  utmSource?: string;
318
318
  utmMedium?: string;
319
319
  utmCampaign?: string;
320
+ gaClientId?: string;
321
+ gaSessionId?: string;
320
322
  };
321
323
  source_metadata?: {
322
324
  pagePath?: string;
@@ -325,12 +327,14 @@ function logBookingSourceDebug(
325
327
  utmSource?: string;
326
328
  utmMedium?: string;
327
329
  utmCampaign?: string;
330
+ gaClientId?: string;
331
+ gaSessionId?: string;
328
332
  };
329
333
  };
330
334
  const md = maybe.sourceMetadata ?? maybe.source_metadata;
331
335
  if (md) {
332
336
  console.info(
333
- `${prefix} summary source=${maybe.source ?? 'n/a'} path=${md.pagePath ?? 'n/a'} partner=${md.partnerId ?? 'n/a'} utm_source=${md.utmSource ?? 'n/a'} utm_medium=${md.utmMedium ?? 'n/a'} utm_campaign=${md.utmCampaign ?? 'n/a'}`
337
+ `${prefix} summary source=${maybe.source ?? 'n/a'} path=${md.pagePath ?? 'n/a'} partner=${md.partnerId ?? 'n/a'} utm_source=${md.utmSource ?? 'n/a'} utm_medium=${md.utmMedium ?? 'n/a'} utm_campaign=${md.utmCampaign ?? 'n/a'} ga_client_id=${md.gaClientId ? 'present' : 'n/a'} ga_session_id=${md.gaSessionId ? 'present' : 'n/a'}`
334
338
  );
335
339
  }
336
340
  }
@@ -1186,6 +1190,8 @@ export interface CreatePaymentIntentResponse {
1186
1190
  clientSecret: string;
1187
1191
  amount: number;
1188
1192
  currency: string;
1193
+ pricingQuoteId?: string | null;
1194
+ pricingQuoteInputsHash?: string | null;
1189
1195
  }
1190
1196
 
1191
1197
  export interface ChangeBookingQuoteRequest {
@@ -1338,6 +1344,15 @@ export interface ChangeBookingQuoteResponse {
1338
1344
  newTotalCents?: number;
1339
1345
  amountDueCents?: number;
1340
1346
  expiresAt?: string;
1347
+ pricingQuoteId?: string;
1348
+ pricingQuoteInputsHash?: string;
1349
+ /** Optional full Pricing V2 quote snapshot when BE prices the change through Pricing V2. */
1350
+ pricingQuote?: PricingV2QuoteSnapshot | null;
1351
+ quote?: PricingV2QuoteSnapshot | null;
1352
+ paymentCreditTotal?: number;
1353
+ balanceDelta?: number;
1354
+ amountToCharge?: number;
1355
+ refundCandidate?: number;
1341
1356
  /**
1342
1357
  * Optional (customer change-quote UI): per-category ticket unit prices in **major units**, display currency.
1343
1358
  * Uppercase keys preferred (ADULT, CHILD, …). Enables full self-serve preview without catalog math.
@@ -1442,12 +1457,18 @@ export async function cancelBookingPublic(
1442
1457
 
1443
1458
  export async function createBalancePaymentIntent(
1444
1459
  bookingReference: string,
1445
- lastName: string
1460
+ lastName: string,
1461
+ pricingQuote?: { pricingQuoteId?: string | null; pricingQuoteInputsHash?: string | null }
1446
1462
  ): Promise<CreatePaymentIntentResponse> {
1447
1463
  const res = await fetch(`${API_BASE}/checkout/balance-payment-intent`, {
1448
1464
  method: 'POST',
1449
1465
  headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
1450
- body: JSON.stringify({ bookingReference, lastName }),
1466
+ body: JSON.stringify({
1467
+ bookingReference,
1468
+ lastName,
1469
+ pricingQuoteId: pricingQuote?.pricingQuoteId,
1470
+ pricingQuoteInputsHash: pricingQuote?.pricingQuoteInputsHash,
1471
+ }),
1451
1472
  });
1452
1473
  if (!res.ok) {
1453
1474
  const err = await res.json();
@@ -1457,6 +1478,31 @@ export async function createBalancePaymentIntent(
1457
1478
  return (data.data ?? data) as CreatePaymentIntentResponse;
1458
1479
  }
1459
1480
 
1481
+ export interface CreateBalancePaymentQuoteResponse {
1482
+ amount: number;
1483
+ currency: string;
1484
+ pricingQuoteId: string;
1485
+ pricingQuoteInputsHash: string;
1486
+ expiresAt: string;
1487
+ }
1488
+
1489
+ export async function createBalancePaymentQuote(
1490
+ bookingReference: string,
1491
+ lastName: string
1492
+ ): Promise<CreateBalancePaymentQuoteResponse> {
1493
+ const res = await fetch(`${API_BASE}/checkout/balance-payment-quote`, {
1494
+ method: 'POST',
1495
+ headers: withBookingOutboundHeaders({ 'Content-Type': 'application/json' }),
1496
+ body: JSON.stringify({ bookingReference, lastName }),
1497
+ });
1498
+ if (!res.ok) {
1499
+ const err = await res.json();
1500
+ throw new Error(err.errorMessage || err.error || 'Failed to price payment');
1501
+ }
1502
+ const data = await res.json();
1503
+ return (data.data ?? data) as CreateBalancePaymentQuoteResponse;
1504
+ }
1505
+
1460
1506
  export async function quoteChangeBooking(
1461
1507
  request: ChangeBookingQuoteRequest
1462
1508
  ): Promise<ChangeBookingQuoteResponse> {
@@ -1849,6 +1895,103 @@ export function summarizeReserveRequestForTelemetry(req: ReserveRequest): Record
1849
1895
  * `/1/reserve` success payload (`ReserveResponseData` on the API).
1850
1896
  * Hold expiry is serialized as `reservationExpiration` (not `expiresAt`).
1851
1897
  */
1898
+ export interface PublicNewBookingQuoteV2Request {
1899
+ productId: string;
1900
+ optionId: string;
1901
+ availabilityId: string;
1902
+ dateTime: string;
1903
+ bookingItems: Array<{ category: string; count: number }>;
1904
+ currency?: string | null;
1905
+ returnAvailabilityId?: string | null;
1906
+ promoCode?: string | null;
1907
+ cancellationPolicyId?: string | null;
1908
+ addOnSelections?: Array<{ addOnId: string; quantity?: number; variantId?: string }> | null;
1909
+ additionalHoursCount?: number | null;
1910
+ allowOverbook?: boolean | null;
1911
+ }
1912
+
1913
+ export interface PricingV2QuoteLineSnapshot {
1914
+ lineId?: string | null;
1915
+ type?: string | null;
1916
+ label?: string | null;
1917
+ amount?: number | null;
1918
+ quantity?: number | null;
1919
+ unitAmount?: number | null;
1920
+ billable?: boolean | null;
1921
+ currency?: string | null;
1922
+ taxBehavior?: string | null;
1923
+ discountBehavior?: string | null;
1924
+ source?: string | null;
1925
+ metadata?: Record<string, string> | null;
1926
+ }
1927
+
1928
+ export interface PricingV2QuoteSnapshot {
1929
+ quoteId?: string | null;
1930
+ pricingVersion?: string | null;
1931
+ companyId?: string | null;
1932
+ context?: string | null;
1933
+ status?: string | null;
1934
+ currency?: string | null;
1935
+ grossSubtotal?: number | null;
1936
+ discountTotal?: number | null;
1937
+ paymentCreditTotal?: number | null;
1938
+ netSubtotalBeforeTax?: number | null;
1939
+ taxableSubtotal?: number | null;
1940
+ taxAmount?: number | null;
1941
+ payableTotal?: number | null;
1942
+ amountPreviouslyPaid?: number | null;
1943
+ balanceDelta?: number | null;
1944
+ amountToCharge?: number | null;
1945
+ refundCandidate?: number | null;
1946
+ totalAmount?: number | null;
1947
+ subtotal?: number | null;
1948
+ tax?: number | null;
1949
+ lines?: PricingV2QuoteLineSnapshot[] | null;
1950
+ inputsHash?: string | null;
1951
+ oldReceiptHash?: string | null;
1952
+ expiresAt?: string | null;
1953
+ bookingReference?: string | null;
1954
+ reservationReference?: string | null;
1955
+ relatedChangeIntentId?: string | null;
1956
+ metadata?: Record<string, string> | null;
1957
+ [key: string]: unknown;
1958
+ }
1959
+
1960
+ export interface PublicNewBookingQuoteV2Data {
1961
+ quote?: PricingV2QuoteSnapshot | null;
1962
+ canBook: boolean;
1963
+ reasonIfBlocked?: string | null;
1964
+ unsupportedFeatures?: string[];
1965
+ pricingV2Enabled?: boolean | null;
1966
+ pricingV2QuoteRequired?: boolean | null;
1967
+ }
1968
+
1969
+ export async function quotePublicNewBookingV2(
1970
+ request: PublicNewBookingQuoteV2Request,
1971
+ signal?: AbortSignal
1972
+ ): Promise<PublicNewBookingQuoteV2Data> {
1973
+ const endpoint = '/1/public/booking-quotes';
1974
+ const res = await fetch(`${API_BASE}${endpoint}`, {
1975
+ method: 'POST',
1976
+ headers: getAuthHeaders(),
1977
+ body: JSON.stringify(request),
1978
+ signal,
1979
+ });
1980
+ const payload = await parseJsonSafely(res);
1981
+ if (!res.ok) {
1982
+ const message = isApiErrorPayload(payload)
1983
+ ? payload.errorMessage || payload.error || `HTTP error ${res.status}`
1984
+ : `HTTP error ${res.status}`;
1985
+ throw new Error(message);
1986
+ }
1987
+ const outer = (payload as { data?: unknown } | null)?.data;
1988
+ const inner = (outer as { data?: PublicNewBookingQuoteV2Data } | null)?.data ?? outer;
1989
+ if (!inner || typeof inner !== 'object') {
1990
+ throw new Error('Invalid response: missing Pricing V2 quote data');
1991
+ }
1992
+ return inner as PublicNewBookingQuoteV2Data;
1993
+ }
1994
+
1852
1995
  export interface ReserveResponse {
1853
1996
  reservationReference: string;
1854
1997
  /** ISO 8601 instant when the hold expires (API field name). */
@@ -2038,6 +2181,9 @@ export interface CreatePaymentIntentRequest {
2038
2181
  checkoutBreakdown?: CheckoutBreakdown;
2039
2182
  dependentAddOnSelection?: CheckoutDependentAddOnSelection;
2040
2183
  itineraryDisplay?: ItineraryDisplayStep[];
2184
+ pricingQuoteId?: string;
2185
+ pricingQuoteInputsHash?: string;
2186
+ idempotencyKey?: string;
2041
2187
  skipConfirmationCommunications?: boolean;
2042
2188
  disableAutoCommunications?: boolean;
2043
2189
  /** Private Shuttle deposit: pay deposit now, balance charged before trip */
@@ -2139,6 +2285,9 @@ export interface ConfirmFreeBookingRequest {
2139
2285
  dependentAddOnSelection?: CheckoutDependentAddOnSelection;
2140
2286
  skipConfirmationCommunications?: boolean;
2141
2287
  disableAutoCommunications?: boolean;
2288
+ /** Pricing V2 checkout quote id; the backend also falls back to the quote stored on the reservation. */
2289
+ pricingQuoteId?: string;
2290
+ pricingQuoteInputsHash?: string;
2142
2291
  source?: string;
2143
2292
  sourceMetadata?: BookingSourceMetadata;
2144
2293
  source_metadata?: BookingSourceMetadata;
@@ -2224,6 +2373,8 @@ export interface ConfirmBookingWithoutPaymentRequest {
2224
2373
  balanceAmount: number;
2225
2374
  totalAmount: number;
2226
2375
  balanceChargeDaysBefore?: number;
2376
+ pricingQuoteId?: string;
2377
+ pricingQuoteInputsHash?: string;
2227
2378
  source?: string;
2228
2379
  sourceMetadata?: BookingSourceMetadata;
2229
2380
  source_metadata?: BookingSourceMetadata;
@@ -0,0 +1,272 @@
1
+ import {
2
+ quotePublicNewBookingV2,
3
+ type PublicNewBookingQuoteV2Data,
4
+ type PublicNewBookingQuoteV2Request,
5
+ } from './booking-api';
6
+
7
+ export const PRICING_V2_SHADOW_NEW_BOOKING_QUOTE_EVENT = 'ticketbooth:pricing-v2-shadow-new-booking-quote';
8
+ export const PRICING_V2_SHADOW_NEW_BOOKING_QUOTE_STORAGE_KEY = 'ticketbooth:pricing-v2-shadow-new-booking-quote:last';
9
+
10
+ export type PricingV2ShadowNewBookingStatus = 'running' | 'matched' | 'mismatch' | 'blocked' | 'failed' | 'timeout';
11
+
12
+ export interface PricingV2ShadowNewBookingQuoteDetail {
13
+ status: PricingV2ShadowNewBookingStatus;
14
+ flow: 'standard' | 'private-shuttle';
15
+ productId: string;
16
+ optionId: string;
17
+ availabilityId: string;
18
+ dateTime: string;
19
+ quoteId?: string | null;
20
+ canBook?: boolean | null;
21
+ reasonIfBlocked?: string | null;
22
+ unsupportedFeatures?: string[];
23
+ clientProposedTotal?: number | null;
24
+ pricingV2Total?: number | null;
25
+ totalDelta?: number | null;
26
+ paymentCreditTotal?: number | null;
27
+ pricingV2LineCount?: number | null;
28
+ pricingV2Status?: string | null;
29
+ pricingV2Enabled?: boolean | null;
30
+ pricingV2QuoteRequired?: boolean | null;
31
+ currency?: string | null;
32
+ error?: string | null;
33
+ timestamp: string;
34
+ }
35
+
36
+ const PRICING_V2_SHADOW_NEW_BOOKING_TIMEOUT_MS = 1500;
37
+ export const PRICING_V2_TOTAL_TOLERANCE = 0.02;
38
+ const PRICING_V2_CHECKOUT_QUOTE_UNAVAILABLE_MESSAGE =
39
+ 'Unable to confirm the current price. Please try again.';
40
+
41
+ function roundMoney(value: number): number {
42
+ return Math.round(value * 100) / 100;
43
+ }
44
+
45
+ function quoteTotal(data: PublicNewBookingQuoteV2Data): number | null {
46
+ const value = data.quote?.amountToCharge ?? data.quote?.payableTotal ?? data.quote?.totalAmount;
47
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
48
+ }
49
+
50
+ function quoteLineCount(data: PublicNewBookingQuoteV2Data): number | null {
51
+ const lines = data.quote?.lines;
52
+ return Array.isArray(lines) ? lines.length : null;
53
+ }
54
+
55
+ function publishPricingV2ShadowNewBookingQuote(detail: PricingV2ShadowNewBookingQuoteDetail): void {
56
+ if (typeof window === 'undefined') return;
57
+ try {
58
+ window.sessionStorage.setItem(PRICING_V2_SHADOW_NEW_BOOKING_QUOTE_STORAGE_KEY, JSON.stringify(detail));
59
+ } catch {
60
+ // Shadow diagnostics should never affect booking checkout.
61
+ }
62
+ window.dispatchEvent(new CustomEvent<PricingV2ShadowNewBookingQuoteDetail>(PRICING_V2_SHADOW_NEW_BOOKING_QUOTE_EVENT, { detail }));
63
+ }
64
+
65
+ function isPricingV2CheckoutQuoteEnabled(data: PublicNewBookingQuoteV2Data): boolean {
66
+ return data.pricingV2Enabled === true;
67
+ }
68
+
69
+ function isPricingV2CheckoutQuoteRequiredByClient(): boolean {
70
+ return process.env.NEXT_PUBLIC_PRICING_V2_CHECKOUT_REQUIRED?.trim().toLowerCase() === 'true';
71
+ }
72
+
73
+ function isPricingV2CheckoutQuoteRequired(data: PublicNewBookingQuoteV2Data): boolean {
74
+ return data.pricingV2QuoteRequired === true || isPricingV2CheckoutQuoteRequiredByClient();
75
+ }
76
+
77
+ export async function shadowQuotePublicNewBookingV2({
78
+ flow,
79
+ request,
80
+ clientProposedTotal,
81
+ }: {
82
+ flow: PricingV2ShadowNewBookingQuoteDetail['flow'];
83
+ request: PublicNewBookingQuoteV2Request;
84
+ clientProposedTotal?: number | null;
85
+ }): Promise<void> {
86
+ if (typeof window === 'undefined') return;
87
+ const controller = new AbortController();
88
+ const timeoutId = window.setTimeout(() => controller.abort(), PRICING_V2_SHADOW_NEW_BOOKING_TIMEOUT_MS);
89
+ const roundedClientTotal = typeof clientProposedTotal === 'number' && Number.isFinite(clientProposedTotal)
90
+ ? roundMoney(clientProposedTotal)
91
+ : null;
92
+
93
+ publishPricingV2ShadowNewBookingQuote({
94
+ status: 'running',
95
+ flow,
96
+ productId: request.productId,
97
+ optionId: request.optionId,
98
+ availabilityId: request.availabilityId,
99
+ dateTime: request.dateTime,
100
+ clientProposedTotal: roundedClientTotal,
101
+ currency: request.currency ?? null,
102
+ timestamp: new Date().toISOString(),
103
+ });
104
+
105
+ try {
106
+ const quote = await quotePublicNewBookingV2(request, controller.signal);
107
+ const pricingV2QuoteRequired = isPricingV2CheckoutQuoteRequired(quote);
108
+ const v2Total = quoteTotal(quote);
109
+ const totalDelta = v2Total != null && roundedClientTotal != null
110
+ ? roundMoney(v2Total - roundedClientTotal)
111
+ : null;
112
+ const status: PricingV2ShadowNewBookingStatus = !quote.canBook
113
+ ? 'blocked'
114
+ : totalDelta != null && Math.abs(totalDelta) > PRICING_V2_TOTAL_TOLERANCE
115
+ ? 'mismatch'
116
+ : 'matched';
117
+ const detail: PricingV2ShadowNewBookingQuoteDetail = {
118
+ status,
119
+ flow,
120
+ productId: request.productId,
121
+ optionId: request.optionId,
122
+ availabilityId: request.availabilityId,
123
+ dateTime: request.dateTime,
124
+ quoteId: quote.quote?.quoteId ?? null,
125
+ canBook: quote.canBook,
126
+ reasonIfBlocked: quote.reasonIfBlocked ?? null,
127
+ unsupportedFeatures: quote.unsupportedFeatures ?? [],
128
+ clientProposedTotal: roundedClientTotal,
129
+ pricingV2Total: v2Total,
130
+ totalDelta,
131
+ paymentCreditTotal: quote.quote?.paymentCreditTotal ?? null,
132
+ pricingV2LineCount: quoteLineCount(quote),
133
+ pricingV2Status: quote.quote?.status ?? null,
134
+ pricingV2Enabled: quote.pricingV2Enabled ?? null,
135
+ pricingV2QuoteRequired,
136
+ currency: quote.quote?.currency ?? request.currency ?? null,
137
+ timestamp: new Date().toISOString(),
138
+ };
139
+ publishPricingV2ShadowNewBookingQuote(detail);
140
+ if (status === 'matched') {
141
+ console.info('[PricingV2 shadow] new booking quote matched', detail);
142
+ } else {
143
+ console.warn('[PricingV2 shadow] new booking quote mismatch', detail);
144
+ }
145
+ } catch (error) {
146
+ const aborted = error instanceof Error && error.name === 'AbortError';
147
+ const pricingV2QuoteRequired = isPricingV2CheckoutQuoteRequiredByClient();
148
+ const detail: PricingV2ShadowNewBookingQuoteDetail = {
149
+ status: aborted ? 'timeout' : 'failed',
150
+ flow,
151
+ productId: request.productId,
152
+ optionId: request.optionId,
153
+ availabilityId: request.availabilityId,
154
+ dateTime: request.dateTime,
155
+ clientProposedTotal: roundedClientTotal,
156
+ pricingV2QuoteRequired,
157
+ currency: request.currency ?? null,
158
+ error: aborted ? 'Quote timed out after 1.5s' : error instanceof Error ? error.message : 'Unknown quote error',
159
+ timestamp: new Date().toISOString(),
160
+ };
161
+ publishPricingV2ShadowNewBookingQuote(detail);
162
+ console.warn('[PricingV2 shadow] new booking quote skipped', detail);
163
+ } finally {
164
+ window.clearTimeout(timeoutId);
165
+ }
166
+ }
167
+
168
+
169
+ export async function quotePublicNewBookingV2ForCheckout({
170
+ flow,
171
+ request,
172
+ clientProposedTotal,
173
+ }: {
174
+ flow: PricingV2ShadowNewBookingQuoteDetail['flow'];
175
+ request: PublicNewBookingQuoteV2Request;
176
+ clientProposedTotal?: number | null;
177
+ }): Promise<PublicNewBookingQuoteV2Data | null> {
178
+ const roundedClientTotal = typeof clientProposedTotal === 'number' && Number.isFinite(clientProposedTotal)
179
+ ? roundMoney(clientProposedTotal)
180
+ : null;
181
+
182
+ publishPricingV2ShadowNewBookingQuote({
183
+ status: 'running',
184
+ flow,
185
+ productId: request.productId,
186
+ optionId: request.optionId,
187
+ availabilityId: request.availabilityId,
188
+ dateTime: request.dateTime,
189
+ clientProposedTotal: roundedClientTotal,
190
+ currency: request.currency ?? null,
191
+ timestamp: new Date().toISOString(),
192
+ });
193
+
194
+ let quote: PublicNewBookingQuoteV2Data;
195
+ try {
196
+ quote = await quotePublicNewBookingV2(request);
197
+ } catch (error) {
198
+ const pricingV2QuoteRequired = isPricingV2CheckoutQuoteRequiredByClient();
199
+ const detail: PricingV2ShadowNewBookingQuoteDetail = {
200
+ status: pricingV2QuoteRequired ? 'blocked' : 'failed',
201
+ flow,
202
+ productId: request.productId,
203
+ optionId: request.optionId,
204
+ availabilityId: request.availabilityId,
205
+ dateTime: request.dateTime,
206
+ clientProposedTotal: roundedClientTotal,
207
+ pricingV2QuoteRequired,
208
+ currency: request.currency ?? null,
209
+ error: error instanceof Error ? error.message : 'Unknown quote error',
210
+ timestamp: new Date().toISOString(),
211
+ };
212
+ publishPricingV2ShadowNewBookingQuote(detail);
213
+ if (pricingV2QuoteRequired) {
214
+ console.warn('[PricingV2 checkout] required new booking quote unavailable', detail);
215
+ throw new Error(PRICING_V2_CHECKOUT_QUOTE_UNAVAILABLE_MESSAGE);
216
+ }
217
+ return null;
218
+ }
219
+
220
+ const v2Total = quoteTotal(quote);
221
+ const totalDelta = v2Total != null && roundedClientTotal != null
222
+ ? roundMoney(v2Total - roundedClientTotal)
223
+ : null;
224
+ const hasRequiredQuoteFields = Boolean(quote.quote?.quoteId) && v2Total != null;
225
+ const pricingV2QuoteRequired = isPricingV2CheckoutQuoteRequired(quote);
226
+ const pricingV2QuoteEnabled = isPricingV2CheckoutQuoteEnabled(quote) || pricingV2QuoteRequired;
227
+ const status: PricingV2ShadowNewBookingStatus = !quote.canBook || !hasRequiredQuoteFields
228
+ ? 'blocked'
229
+ : totalDelta != null && Math.abs(totalDelta) > PRICING_V2_TOTAL_TOLERANCE
230
+ ? 'mismatch'
231
+ : 'matched';
232
+ const detail: PricingV2ShadowNewBookingQuoteDetail = {
233
+ status,
234
+ flow,
235
+ productId: request.productId,
236
+ optionId: request.optionId,
237
+ availabilityId: request.availabilityId,
238
+ dateTime: request.dateTime,
239
+ quoteId: quote.quote?.quoteId ?? null,
240
+ canBook: quote.canBook,
241
+ reasonIfBlocked: quote.reasonIfBlocked ?? null,
242
+ unsupportedFeatures: quote.unsupportedFeatures ?? [],
243
+ clientProposedTotal: roundedClientTotal,
244
+ pricingV2Total: v2Total,
245
+ totalDelta,
246
+ paymentCreditTotal: quote.quote?.paymentCreditTotal ?? null,
247
+ pricingV2LineCount: quoteLineCount(quote),
248
+ pricingV2Status: quote.quote?.status ?? null,
249
+ pricingV2Enabled: quote.pricingV2Enabled ?? null,
250
+ pricingV2QuoteRequired,
251
+ currency: quote.quote?.currency ?? request.currency ?? null,
252
+ timestamp: new Date().toISOString(),
253
+ };
254
+ publishPricingV2ShadowNewBookingQuote(detail);
255
+
256
+ if (!pricingV2QuoteEnabled) {
257
+ return null;
258
+ }
259
+
260
+ if (status === 'blocked') {
261
+ if (pricingV2QuoteRequired && !hasRequiredQuoteFields) {
262
+ throw new Error(PRICING_V2_CHECKOUT_QUOTE_UNAVAILABLE_MESSAGE);
263
+ }
264
+ if (!pricingV2QuoteRequired) {
265
+ return null;
266
+ }
267
+ const reason = quote.reasonIfBlocked ?? 'quote_not_applicable';
268
+ throw new Error(`Pricing quote blocked: ${reason}`);
269
+ }
270
+
271
+ return quote;
272
+ }