@ticketboothapp/booking 1.2.24 → 1.2.25

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 (158) hide show
  1. package/package.json +29 -2
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/BookingWidget.tsx +305 -0
  9. package/src/components/ManageBookingView.tsx +437 -0
  10. package/src/components/PhoneInputWithCountry.module.css +131 -0
  11. package/src/components/PhoneInputWithCountry.tsx +44 -0
  12. package/src/components/PickupLocationDialog.module.css +360 -0
  13. package/src/components/PickupLocationDialog.tsx +357 -0
  14. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  15. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  16. package/src/components/booking/AddOnsSection.module.css +10 -0
  17. package/src/components/booking/AddOnsSection.tsx +184 -0
  18. package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
  19. package/src/components/booking/BookingDialog.module.css +643 -0
  20. package/src/components/booking/BookingDialog.tsx +356 -0
  21. package/src/components/booking/BookingFlow.tsx +4385 -0
  22. package/src/components/booking/BookingFlowCollage.module.css +148 -0
  23. package/src/components/booking/BookingFlowCollage.tsx +184 -0
  24. package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
  25. package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
  26. package/src/components/booking/BookingFlowPreview.tsx +51 -0
  27. package/src/components/booking/BookingProductGrid.module.css +359 -0
  28. package/src/components/booking/BookingProductGrid.tsx +497 -0
  29. package/src/components/booking/Calendar.module.css +616 -0
  30. package/src/components/booking/Calendar.tsx +1123 -0
  31. package/src/components/booking/CancellationPolicySelector.module.css +124 -0
  32. package/src/components/booking/CancellationPolicySelector.tsx +142 -0
  33. package/src/components/booking/ChangeBookingDialog.tsx +562 -0
  34. package/src/components/booking/CheckoutForm.module.css +244 -0
  35. package/src/components/booking/CheckoutForm.tsx +364 -0
  36. package/src/components/booking/CheckoutModal.tsx +451 -0
  37. package/src/components/booking/CurrencySwitcher.tsx +81 -0
  38. package/src/components/booking/DapFlowCollage.tsx +88 -0
  39. package/src/components/booking/DapTourDescription.tsx +35 -0
  40. package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
  41. package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
  42. package/src/components/booking/ErrorBoundary.tsx +63 -0
  43. package/src/components/booking/InfoTooltip.tsx +108 -0
  44. package/src/components/booking/ItineraryBox.module.css +258 -0
  45. package/src/components/booking/ItineraryBox.tsx +550 -0
  46. package/src/components/booking/ItineraryBuilder.tsx +82 -0
  47. package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
  48. package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
  49. package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
  50. package/src/components/booking/PickupLocationSelector.module.css +124 -0
  51. package/src/components/booking/PickupLocationSelector.tsx +1566 -0
  52. package/src/components/booking/PickupTimeSelector.module.css +134 -0
  53. package/src/components/booking/PickupTimeSelector.tsx +112 -0
  54. package/src/components/booking/PriceBreakdown.tsx +154 -0
  55. package/src/components/booking/PriceSummary.tsx +234 -0
  56. package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
  57. package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
  58. package/src/components/booking/PromoCodeInput.module.css +166 -0
  59. package/src/components/booking/PromoCodeInput.tsx +99 -0
  60. package/src/components/booking/ReturnTimeSelector.module.css +173 -0
  61. package/src/components/booking/ReturnTimeSelector.tsx +145 -0
  62. package/src/components/booking/TermsAcceptance.tsx +111 -0
  63. package/src/components/booking/TicketSelector.module.css +164 -0
  64. package/src/components/booking/TicketSelector.tsx +199 -0
  65. package/src/components/booking/TourDescription.module.css +304 -0
  66. package/src/components/booking/TourDescription.tsx +273 -0
  67. package/src/components/booking/booking-flow-ui.ts +38 -0
  68. package/src/components/booking/booking-flow.css +944 -0
  69. package/src/components/button.css +245 -0
  70. package/src/components/button.tsx +152 -0
  71. package/src/components/colorable-svg.tsx +29 -0
  72. package/src/components/image.css +29 -0
  73. package/src/components/image.tsx +113 -0
  74. package/src/components/partner/PartnerBookingPage.module.css +130 -0
  75. package/src/components/partner/PartnerBookingPage.tsx +390 -0
  76. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
  77. package/src/components/product-tag.module.css +30 -0
  78. package/src/components/product-tag.tsx +34 -0
  79. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  80. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  81. package/src/components/terms/TermsContent.tsx +178 -0
  82. package/src/components/value-pill.module.css +59 -0
  83. package/src/components/value-pill.tsx +46 -0
  84. package/src/constants/images.ts +556 -0
  85. package/src/constants/pill-values.ts +210 -0
  86. package/src/constants/products.ts +155 -0
  87. package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
  88. package/src/contexts/BookingAppContext.tsx +134 -0
  89. package/src/contexts/CompanyContext.tsx +70 -0
  90. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  91. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  92. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  93. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  94. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  95. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  96. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  97. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  98. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  99. package/src/data/product-descriptions/private-tour.en.json +80 -0
  100. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  101. package/src/data/products-config.json +101 -0
  102. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  103. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  104. package/src/index.ts +79 -0
  105. package/src/lib/analytics.ts +197 -0
  106. package/src/lib/booking/booking-source.ts +51 -0
  107. package/src/lib/booking/checkout-breakdown.ts +69 -0
  108. package/src/lib/booking/correlation-id.ts +46 -0
  109. package/src/lib/booking/i18n/config.ts +21 -0
  110. package/src/lib/booking/i18n/index.tsx +144 -0
  111. package/src/lib/booking/i18n/messages/en.json +236 -0
  112. package/src/lib/booking/i18n/messages/fr.json +236 -0
  113. package/src/lib/booking/itinerary-display.ts +36 -0
  114. package/src/lib/booking/itinerary-labels.ts +70 -0
  115. package/src/lib/booking/location-calculations.ts +43 -0
  116. package/src/lib/booking/location-utils.ts +165 -0
  117. package/src/lib/booking/map-utils.ts +153 -0
  118. package/src/lib/booking/marker-icons.ts +113 -0
  119. package/src/lib/booking/normalize-booking-product-id.ts +21 -0
  120. package/src/lib/booking/pickup-location-types.ts +25 -0
  121. package/src/lib/booking/places-api.ts +154 -0
  122. package/src/lib/booking/pricing.ts +466 -0
  123. package/src/lib/booking/product-option-id.ts +35 -0
  124. package/src/lib/booking/source-metadata.ts +226 -0
  125. package/src/lib/booking/sunday-week.ts +14 -0
  126. package/src/lib/booking/theme.ts +83 -0
  127. package/src/lib/booking/trace-context.ts +62 -0
  128. package/src/lib/booking/utils.ts +9 -0
  129. package/src/lib/booking-api.ts +1793 -0
  130. package/src/lib/booking-constants.ts +23 -0
  131. package/src/lib/booking-ref.ts +13 -0
  132. package/src/lib/booking-types.ts +36 -0
  133. package/src/lib/currency.ts +81 -0
  134. package/src/lib/dap-descriptions.ts +50 -0
  135. package/src/lib/dap-itinerary-preview.ts +315 -0
  136. package/src/lib/dependent-add-on-api.ts +434 -0
  137. package/src/lib/env.ts +96 -0
  138. package/src/lib/firebase.ts +20 -0
  139. package/src/lib/job-application-api.ts +83 -0
  140. package/src/lib/manage-booking-embed-print.ts +16 -0
  141. package/src/lib/manage-booking-post-checkout.ts +68 -0
  142. package/src/lib/photo-dap-config.ts +228 -0
  143. package/src/lib/photo-packages.ts +75 -0
  144. package/src/lib/pickup/map-utils.ts +56 -0
  145. package/src/lib/pickup/marker-icons.ts +19 -0
  146. package/src/lib/product-descriptions.ts +66 -0
  147. package/src/lib/products-config.ts +73 -0
  148. package/src/providers/booking-dialog-provider.tsx +282 -0
  149. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  150. package/src/radius.css +5 -0
  151. package/src/spacing.css +7 -0
  152. package/src/strings/en.json +1774 -0
  153. package/src/strings/es.json +1573 -0
  154. package/src/strings/fr.json +1573 -0
  155. package/src/strings/index.js +23 -0
  156. package/src/text-style.css +56 -0
  157. package/src/utils/currency-converter.ts +101 -0
  158. package/tsconfig.json +8 -2
@@ -0,0 +1,466 @@
1
+ import type { Currency } from '@/components/booking/CurrencySwitcher';
2
+ import type { PricingConfig } from '@/lib/booking-api';
3
+ import { DEFAULT_EXCHANGE_RATES } from '@/lib/currency';
4
+
5
+ const BASE_CURRENCY = 'CAD' as const;
6
+
7
+ /**
8
+ * Validate that pricingConfig has required fields and valid values.
9
+ * Returns true if valid, logs warnings and returns false if invalid.
10
+ */
11
+ export function validatePricingConfig(config: PricingConfig | null): config is PricingConfig {
12
+ if (!config) {
13
+ console.warn('[Pricing] pricingConfig is null or undefined');
14
+ return false;
15
+ }
16
+
17
+ if (typeof config.taxRate !== 'number' || config.taxRate <= 0 || config.taxRate > 1) {
18
+ console.warn('[Pricing] Invalid taxRate:', config.taxRate);
19
+ return false;
20
+ }
21
+
22
+ if (!Array.isArray(config.currenciesWithTaxIncluded)) {
23
+ console.warn('[Pricing] currenciesWithTaxIncluded must be an array');
24
+ return false;
25
+ }
26
+
27
+ if (config.fees != null && typeof config.fees !== 'object') {
28
+ console.warn('[Pricing] fees must be an object when present');
29
+ return false;
30
+ }
31
+
32
+ return true;
33
+ }
34
+
35
+ /**
36
+ * Get exchange rates from pricingConfig with fallback to defaults.
37
+ * Logs a warning if fallback is used.
38
+ */
39
+ function getExchangeRates(pricingConfig: PricingConfig): Record<string, number> {
40
+ if (pricingConfig.exchangeRates && Object.keys(pricingConfig.exchangeRates).length > 0) {
41
+ return pricingConfig.exchangeRates;
42
+ }
43
+
44
+ console.warn('[Pricing] Exchange rates not provided in pricingConfig, using fallback rates');
45
+ return {
46
+ USD: DEFAULT_EXCHANGE_RATES.USD,
47
+ EUR: DEFAULT_EXCHANGE_RATES.EUR,
48
+ GBP: DEFAULT_EXCHANGE_RATES.GBP,
49
+ AUD: DEFAULT_EXCHANGE_RATES.AUD,
50
+ };
51
+ }
52
+
53
+ /** Tax rate as display percentage (e.g. 0.05 → 5, 0.085 → 8.5). UI displays only; no calculation in UI. */
54
+ export function formatTaxRatePercent(taxRate: number): number {
55
+ return Math.round(taxRate * 10000) / 100;
56
+ }
57
+
58
+ /**
59
+ * Internal: compute ticket price and all intermediates. Single place for pricing logic.
60
+ * Must stay in sync with BE when generating prices (rounding only on BE).
61
+ * When baseInDisplayCurrency is provided (e.g. from precomputedPrices), use it as the backend
62
+ * price in display currency so the breakdown matches the displayed ticket price.
63
+ */
64
+ function computeTicketPriceDetails(
65
+ pricingConfig: PricingConfig,
66
+ currency: Currency,
67
+ backendPriceCAD: number,
68
+ hasFees: boolean,
69
+ baseInDisplayCurrency?: number
70
+ ): {
71
+ finalPrice: number;
72
+ priceAfterConversion: number;
73
+ feeAmount: number;
74
+ feeLines: Array<{ name: string; feeAmount: number; description?: string }>;
75
+ taxAmount: number;
76
+ isTaxIncluded: boolean;
77
+ exchangeRate: number;
78
+ } {
79
+ const exchangeRates = getExchangeRates(pricingConfig);
80
+ const isTaxIncluded = pricingConfig.currenciesWithTaxIncluded.includes(currency);
81
+ const byCurrency = hasFees ? pricingConfig.feesByCurrency?.[currency] : undefined;
82
+ const fees = pricingConfig.fees ?? {};
83
+ const feeLines =
84
+ byCurrency != null
85
+ ? Object.entries(byCurrency).map(([name, amount]) => ({
86
+ name,
87
+ feeAmount: amount,
88
+ description: fees[name]?.description,
89
+ }))
90
+ : [];
91
+ const feeAmount = feeLines.reduce((sum, f) => sum + f.feeAmount, 0);
92
+
93
+ let priceAfterConversion: number;
94
+ let finalPrice: number;
95
+ let taxAmount: number;
96
+
97
+ const usePrecomputedBase = baseInDisplayCurrency != null && currency !== BASE_CURRENCY;
98
+
99
+ if (isTaxIncluded) {
100
+ priceAfterConversion = usePrecomputedBase
101
+ ? baseInDisplayCurrency!
102
+ : (currency === BASE_CURRENCY ? backendPriceCAD : 0);
103
+ const preTaxAmount = priceAfterConversion + feeAmount;
104
+ finalPrice = preTaxAmount * (1 + pricingConfig.taxRate);
105
+ taxAmount = preTaxAmount * pricingConfig.taxRate;
106
+ } else {
107
+ if (currency === BASE_CURRENCY) {
108
+ priceAfterConversion = backendPriceCAD;
109
+ finalPrice = backendPriceCAD;
110
+ } else {
111
+ priceAfterConversion = usePrecomputedBase ? baseInDisplayCurrency! : 0;
112
+ finalPrice = priceAfterConversion;
113
+ }
114
+ taxAmount = 0;
115
+ }
116
+
117
+ const exchangeRate = currency === BASE_CURRENCY ? 1 : (exchangeRates[currency] ?? 1);
118
+
119
+ return {
120
+ finalPrice,
121
+ priceAfterConversion,
122
+ feeAmount,
123
+ feeLines,
124
+ taxAmount,
125
+ isTaxIncluded,
126
+ exchangeRate,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Display price per ticket when base is already in display currency (from ticketbooth-product-prices).
132
+ * For CAD/USD: returns base (fee/tax shown as separate line items).
133
+ * For EUR/GBP/AUD: returns (base + fee per person) * (1 + tax).
134
+ */
135
+ export function getDisplayPriceFromBaseInDisplayCurrency(
136
+ baseInDisplayCurrency: number,
137
+ currency: Currency,
138
+ pricingConfig: PricingConfig,
139
+ hasFees: boolean
140
+ ): number {
141
+ if (baseInDisplayCurrency <= 0) return 0;
142
+ if (!validatePricingConfig(pricingConfig)) return 0;
143
+ const isTaxIncluded = pricingConfig.currenciesWithTaxIncluded.includes(currency);
144
+ const byCurrency = hasFees ? pricingConfig.feesByCurrency?.[currency] : undefined;
145
+ const feeAmount =
146
+ byCurrency != null ? Object.values(byCurrency).reduce((s, a) => s + a, 0) : 0;
147
+ if (isTaxIncluded) {
148
+ return (baseInDisplayCurrency + feeAmount) * (1 + pricingConfig.taxRate);
149
+ }
150
+ return baseInDisplayCurrency;
151
+ }
152
+
153
+ /** Applied adjustment from API. BE sends changeByCurrency; FE uses that amount only (no conversion). */
154
+ export interface PriceBreakdownAdjustment {
155
+ type: string;
156
+ id: string;
157
+ name: string;
158
+ changeByCurrency?: Record<string, number>;
159
+ adjustmentType?: string; // "percentage" or "fixed"
160
+ adjustmentValue?: number; // e.g. -10.0 for 10% off, or fixed amount in CAD
161
+ }
162
+
163
+ // --- Mid-layer: price breakdown (no UI). Use this output in tooltip, summary, etc. ---
164
+
165
+ /** One line in the price breakdown. Amount is in display currency (from BE). */
166
+ export interface PriceBreakdownLineItem {
167
+ type: 'base' | 'adjustment';
168
+ id?: string;
169
+ name: string;
170
+ amountInDisplayCurrency: number;
171
+ adjustmentType?: string; // "percentage" or "fixed"
172
+ adjustmentValue?: number; // e.g. 10.0 for 10%, or fixed amount in CAD
173
+ sourceType?: 'deal' | 'dynamic'; // Whether this adjustment is from a deal or dynamic pricing rule
174
+ }
175
+
176
+ /** Result of the price breakdown calculation. Consume in tooltip, order summary, or any other booking UI. */
177
+ export interface PriceBreakdown {
178
+ lineItems: PriceBreakdownLineItem[];
179
+ subtotalAfterAdjustments: number;
180
+ feeLines: FeeLineForDisplay[];
181
+ taxRate: number;
182
+ taxAmount: number;
183
+ isTaxIncluded: boolean;
184
+ finalPrice: number;
185
+ }
186
+
187
+ /** One fee line for tooltip display (name from config, amount per person in display currency from BE). */
188
+ export interface FeeLineForDisplay {
189
+ name: string;
190
+ feeAmount: number;
191
+ description?: string;
192
+ }
193
+
194
+ /** Full breakdown for price tooltip. Must match computeTicketPriceDetails logic. */
195
+ export interface PriceBreakdownForDisplay {
196
+ /** Only true when we convert in mid-layer (e.g. backend price → display currency); show rate only then. */
197
+ showConversionRate: boolean;
198
+ exchangeRate: number;
199
+ /** Base price in display currency — from ticketbooth-product-prices (via API) for every currency including CAD when available. */
200
+ basePriceInDisplayCurrency: number;
201
+ /** Backend price (base + dynamic pricing/deals) in display currency; this is the ticket price before fees/tax. */
202
+ backendPriceInDisplayCurrency: number;
203
+ feeAmount: number;
204
+ feeLines: FeeLineForDisplay[];
205
+ taxRate: number;
206
+ taxAmount: number;
207
+ /** Reserved for future rounding; currently 0. Rounding only on BE when generating prices. */
208
+ roundingAmount: number;
209
+ isTaxIncluded: boolean;
210
+ finalPrice: number;
211
+ }
212
+
213
+ /**
214
+ * Build step-by-step breakdown for tooltip. Uses same logic as computeTicketPriceDetails.
215
+ * Base price comes from ticketbooth-product-prices (via API) for every currency including CAD when provided; never converted on the FE.
216
+ * Conversion rate is only shown when we convert backend (add-ons/dynamic/deals) on the FE.
217
+ */
218
+ export function getPriceBreakdownForDisplay(
219
+ pricingConfig: PricingConfig,
220
+ currency: Currency,
221
+ backendPriceCAD: number,
222
+ basePriceCAD: number,
223
+ hasFees: boolean,
224
+ appliedAdjustments: PriceBreakdownAdjustment[],
225
+ baseInDisplayCurrency?: number,
226
+ basePriceInDisplayCurrencyFromApi?: number
227
+ ): PriceBreakdownForDisplay {
228
+ const details = computeTicketPriceDetails(
229
+ pricingConfig,
230
+ currency,
231
+ backendPriceCAD,
232
+ hasFees,
233
+ baseInDisplayCurrency
234
+ );
235
+
236
+ const showConversionRate =
237
+ currency !== BASE_CURRENCY && baseInDisplayCurrency == null;
238
+
239
+ // Base price: from ticketbooth-product-prices (via API) for every currency including CAD when provided; else fallback to product-option CAD only when currency is CAD.
240
+ const basePriceInDisplayCurrency =
241
+ basePriceInDisplayCurrencyFromApi ??
242
+ (currency === BASE_CURRENCY ? basePriceCAD : 0);
243
+
244
+ return {
245
+ showConversionRate,
246
+ exchangeRate: details.exchangeRate,
247
+ basePriceInDisplayCurrency,
248
+ backendPriceInDisplayCurrency: details.priceAfterConversion,
249
+ feeAmount: details.feeAmount,
250
+ feeLines: details.feeLines,
251
+ taxRate: pricingConfig.taxRate,
252
+ taxAmount: details.taxAmount,
253
+ roundingAmount: 0,
254
+ isTaxIncluded: details.isTaxIncluded,
255
+ finalPrice: details.finalPrice,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * Mid-layer: compute price breakdown as line items (base + one per adjustment). No UI.
261
+ * Use the result in tooltip, order summary, or anywhere in the booking UI.
262
+ * - Base: from ticketbooth-product-prices (via API) for every currency including CAD when provided; never converted on FE.
263
+ * - Adjustments: BE sends changeByCurrency; FE uses that amount only (no conversion).
264
+ * - Exchange rate is never a separate line; show in brackets on each line that used conversion.
265
+ *
266
+ * @param isPublicMode - When true (simplified view), rolls dynamic pricing increases into base price and hides them
267
+ */
268
+ export function computePriceBreakdown(
269
+ pricingConfig: PricingConfig,
270
+ currency: Currency,
271
+ backendPriceCAD: number,
272
+ basePriceCAD: number,
273
+ hasFees: boolean,
274
+ appliedAdjustments: PriceBreakdownAdjustment[],
275
+ baseInDisplayCurrency?: number,
276
+ basePriceInDisplayCurrencyFromApi?: number,
277
+ isPublicMode?: boolean
278
+ ): PriceBreakdown {
279
+ const baseAmount =
280
+ basePriceInDisplayCurrencyFromApi ??
281
+ (currency === BASE_CURRENCY ? basePriceCAD : 0);
282
+
283
+ const lineItems: PriceBreakdownLineItem[] = [];
284
+
285
+ // For public mode: calculate which dynamic adjustments are increases (to be rolled into base)
286
+ let dynamicIncreaseSum = 0;
287
+ if (isPublicMode) {
288
+ for (const adj of appliedAdjustments) {
289
+ const amountInDisplayCurrency = adj.changeByCurrency?.[currency];
290
+ if (amountInDisplayCurrency == null || amountInDisplayCurrency === 0) continue;
291
+
292
+ // Dynamic pricing increase: roll into base price, don't show separately
293
+ if (adj.type === 'dynamic' && amountInDisplayCurrency > 0) {
294
+ dynamicIncreaseSum += amountInDisplayCurrency;
295
+ }
296
+ }
297
+ }
298
+
299
+ // Base price (with dynamic increases rolled in for public mode)
300
+ const displayBaseAmount = baseAmount + dynamicIncreaseSum;
301
+ if (displayBaseAmount > 0) {
302
+ lineItems.push({
303
+ type: 'base',
304
+ name: isPublicMode && dynamicIncreaseSum > 0 ? 'Dynamic price' : 'Base price',
305
+ amountInDisplayCurrency: displayBaseAmount,
306
+ });
307
+ }
308
+
309
+ let adjustmentSum = 0;
310
+ for (const adj of appliedAdjustments) {
311
+ const amountInDisplayCurrency = adj.changeByCurrency?.[currency];
312
+ if (amountInDisplayCurrency == null || amountInDisplayCurrency === 0) continue;
313
+
314
+ // In public mode: skip dynamic pricing increases (they're rolled into base)
315
+ if (isPublicMode && adj.type === 'dynamic' && amountInDisplayCurrency > 0) {
316
+ adjustmentSum += amountInDisplayCurrency; // Still count for subtotal calculation
317
+ continue; // Don't add to lineItems
318
+ }
319
+
320
+ adjustmentSum += amountInDisplayCurrency;
321
+ lineItems.push({
322
+ type: 'adjustment',
323
+ id: adj.id,
324
+ name: adj.name,
325
+ amountInDisplayCurrency,
326
+ adjustmentType: adj.adjustmentType,
327
+ adjustmentValue: adj.adjustmentValue,
328
+ sourceType: adj.type === 'deal' || adj.type === 'dynamic' ? adj.type : undefined,
329
+ });
330
+ }
331
+
332
+ // Subtotal in display currency = base + adjustments; pass so final price is (subtotal + fees) * (1+tax) and breakdown adds up
333
+ const subtotalInDisplayCurrency = baseAmount + adjustmentSum;
334
+ const details = computeTicketPriceDetails(
335
+ pricingConfig,
336
+ currency,
337
+ backendPriceCAD,
338
+ hasFees,
339
+ subtotalInDisplayCurrency > 0 ? subtotalInDisplayCurrency : baseInDisplayCurrency
340
+ );
341
+
342
+ return {
343
+ lineItems,
344
+ subtotalAfterAdjustments: details.priceAfterConversion,
345
+ feeLines: details.feeLines,
346
+ taxRate: pricingConfig.taxRate,
347
+ taxAmount: details.taxAmount,
348
+ isTaxIncluded: details.isTaxIncluded,
349
+ finalPrice: details.finalPrice,
350
+ };
351
+ }
352
+
353
+ /** One fee line for order summary (name, total amount in display currency, optional description). */
354
+ export interface OrderSummaryFeeLine {
355
+ name: string;
356
+ totalAmount: number;
357
+ description?: string;
358
+ }
359
+
360
+ /** One ticket line for order summary (category, qty, price per unit, item total in display currency). */
361
+ export interface OrderSummaryTicketLine {
362
+ category: string;
363
+ qty: number;
364
+ pricePerUnit: number;
365
+ itemTotal: number;
366
+ }
367
+
368
+ /** Order summary for booking flow: all values in display currency. UI displays only; no calculations. */
369
+ export interface OrderSummary {
370
+ subtotal: number;
371
+ tax: number;
372
+ total: number;
373
+ feeLineItems: OrderSummaryFeeLine[];
374
+ returnPriceAdjustment: number;
375
+ /** Per-booking fee for upgraded (flexible) cancellation when selected; 0 otherwise. */
376
+ cancellationPolicyFee: number;
377
+ totalQuantity: number;
378
+ isTaxIncludedInPrice: boolean;
379
+ /** Ticket lines with itemTotal so UI does not compute qty × price. */
380
+ ticketLineItems: OrderSummaryTicketLine[];
381
+ }
382
+
383
+ /**
384
+ * Compute order summary (subtotal, tax, total, fee lines, return adjustment, cancellation upgrade) in display currency.
385
+ * Single source of truth for booking flow totals; UI layer only displays these values.
386
+ * Return adjustment: use selectedReturnOption.priceAdjustmentByCurrency[currency] only.
387
+ * Cancellation fee: use pricingConfig.cancellationPolicies[id].feeByCurrency[currency] when cancellationPolicyId is set.
388
+ */
389
+ export function computeOrderSummary(
390
+ quantities: Record<string, number>,
391
+ pricing: Array<{ category: string; price: number }>,
392
+ selectedReturnOption: { priceAdjustmentByCurrency?: Record<string, number> } | null,
393
+ pricingConfig: PricingConfig | null,
394
+ currency: Currency,
395
+ hasFees: boolean,
396
+ cancellationPolicyId?: string | null
397
+ ): OrderSummary {
398
+ const totalQuantity = Object.values(quantities).reduce((a, b) => a + b, 0);
399
+
400
+ if (!pricingConfig || !validatePricingConfig(pricingConfig)) {
401
+ return {
402
+ subtotal: 0,
403
+ tax: 0,
404
+ total: 0,
405
+ feeLineItems: [],
406
+ returnPriceAdjustment: 0,
407
+ cancellationPolicyFee: 0,
408
+ totalQuantity,
409
+ isTaxIncludedInPrice: false,
410
+ ticketLineItems: [],
411
+ };
412
+ }
413
+
414
+ const isTaxIncludedInPrice = pricingConfig.currenciesWithTaxIncluded.includes(currency);
415
+
416
+ const basePrice = pricing.reduce((sum, rate) => {
417
+ const qty = quantities[rate.category] ?? 0;
418
+ return sum + qty * (rate.price ?? 0);
419
+ }, 0);
420
+
421
+ const perPersonInDisplay = selectedReturnOption?.priceAdjustmentByCurrency?.[currency] ?? 0;
422
+ const returnPriceAdjustment =
423
+ totalQuantity > 0 && perPersonInDisplay !== 0 ? totalQuantity * perPersonInDisplay : 0;
424
+
425
+ const cancellationPolicyFee =
426
+ cancellationPolicyId && pricingConfig?.cancellationPolicies?.length
427
+ ? (pricingConfig.cancellationPolicies.find((p) => p.id === cancellationPolicyId)?.feeByCurrency?.[currency] ?? 0)
428
+ : 0;
429
+
430
+ const fees = pricingConfig.fees ?? {};
431
+ const byCurrency = hasFees ? pricingConfig.feesByCurrency?.[currency] : undefined;
432
+ const feeLineItems: OrderSummaryFeeLine[] =
433
+ !hasFees || totalQuantity === 0 || isTaxIncludedInPrice || byCurrency == null
434
+ ? []
435
+ : Object.entries(byCurrency).map(([name, amountPerPerson]) => ({
436
+ name,
437
+ totalAmount: totalQuantity * amountPerPerson,
438
+ description: fees[name]?.description,
439
+ }));
440
+
441
+ const feesTotal = feeLineItems.reduce((s, f) => s + f.totalAmount, 0);
442
+ const subtotal = basePrice + returnPriceAdjustment + cancellationPolicyFee + feesTotal;
443
+ const tax = isTaxIncludedInPrice ? 0 : subtotal * pricingConfig.taxRate;
444
+ const total = subtotal + tax;
445
+
446
+ const ticketLineItems: OrderSummaryTicketLine[] = pricing
447
+ .map((rate) => {
448
+ const qty = quantities[rate.category] ?? 0;
449
+ if (qty === 0) return null;
450
+ const pricePerUnit = rate.price ?? 0;
451
+ return { category: rate.category, qty, pricePerUnit, itemTotal: qty * pricePerUnit };
452
+ })
453
+ .filter((line): line is OrderSummaryTicketLine => line != null);
454
+
455
+ return {
456
+ subtotal,
457
+ tax,
458
+ total,
459
+ feeLineItems,
460
+ returnPriceAdjustment,
461
+ cancellationPolicyFee,
462
+ totalQuantity,
463
+ isTaxIncludedInPrice,
464
+ ticketLineItems,
465
+ };
466
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * TicketBooth / GYG-style ids: bookings often persist parent product_id (`p_…`) while
3
+ * availabilities and change sessions use option ids (`po_…`). Match backend semantics:
4
+ * never use a parent id where an option id is required (e.g. change-booking preselection).
5
+ */
6
+
7
+ export function isParentProductId(id: string | null | undefined): boolean {
8
+ const t = id?.trim();
9
+ if (!t) return false;
10
+ return t.startsWith('p_') && !t.startsWith('po_');
11
+ }
12
+
13
+ /** Returns the id if it is usable as a product option id for matching availabilities; otherwise null. */
14
+ export function normalizeProductOptionIdForChangeFlow(
15
+ id: string | null | undefined
16
+ ): string | null {
17
+ const t = id?.trim() || null;
18
+ if (!t || isParentProductId(t)) return null;
19
+ return t;
20
+ }
21
+
22
+ /**
23
+ * Option id to seed change-booking UI: prefer explicit productOptionId, else productId when it is
24
+ * already an option id (e.g. some payloads).
25
+ */
26
+ export function effectiveProductOptionIdForChangeFlow(booking: {
27
+ productId?: string | null;
28
+ productOptionId?: string | null;
29
+ }): string | null {
30
+ const fromOption = normalizeProductOptionIdForChangeFlow(booking.productOptionId);
31
+ if (fromOption) return fromOption;
32
+ const pid = booking.productId?.trim() || null;
33
+ if (pid?.startsWith('po_')) return pid;
34
+ return null;
35
+ }