@ticketboothapp/booking 1.2.123 → 1.2.125

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 +155 -3
  111. package/src/lib/pricing-v2-shadow.ts +272 -0
  112. package/test/change-booking-helpers.test.ts +206 -0
@@ -0,0 +1,169 @@
1
+ import { useMemo } from 'react';
2
+ import type { Availability, ReturnOption } from '../../lib/booking-api';
3
+ import type {
4
+ OrderSummaryFeeLine,
5
+ OrderSummaryTicketLine,
6
+ PriceBreakdown as PriceBreakdownData,
7
+ } from '../../lib/booking/pricing';
8
+ import type { PriceSummaryLine } from './PriceSummary';
9
+ import {
10
+ mergePickerTicketQtyIntoPriceSummaryLines,
11
+ omitZeroAmountPromoDiscountSummaryLines,
12
+ type PricingRate,
13
+ } from './change-booking-flow-helpers';
14
+
15
+ type GetPriceBreakdown = (
16
+ category: string,
17
+ priceCAD: number,
18
+ baseInDisplayCurrency: number | undefined,
19
+ appliedAdjustments?: Array<{ type: string; id: string; name: string; changeByCurrency?: Record<string, number> }>,
20
+ ) => PriceBreakdownData | null;
21
+
22
+ interface UseChangeBookingSummaryLinesParams {
23
+ selectedAvailability: Availability | null;
24
+ selectedReturnOption: ReturnOption | null;
25
+ ticketLineItemsForChangeFlowDisplay: OrderSummaryTicketLine[];
26
+ pricing: PricingRate[];
27
+ getPriceBreakdown: GetPriceBreakdown;
28
+ checkoutReturnLineAmount: number;
29
+ totalQuantity: number;
30
+ t: (key: string) => string;
31
+ cancellationPolicyFee: number;
32
+ hasSelectedCancellationPolicy: boolean;
33
+ effectiveCancellationPolicyLabel: string;
34
+ feeLineItemsWithAddOns: OrderSummaryFeeLine[];
35
+ feeLineItems: OrderSummaryFeeLine[];
36
+ suppressSelfServeCurrencyUi: boolean;
37
+ selfServePricingConfirmed: boolean;
38
+ latestServerPriceSummaryLines?: PriceSummaryLine[];
39
+ quantities: Record<string, number>;
40
+ }
41
+
42
+ export function useChangeBookingSummaryLines({
43
+ selectedAvailability,
44
+ selectedReturnOption,
45
+ ticketLineItemsForChangeFlowDisplay,
46
+ pricing,
47
+ getPriceBreakdown,
48
+ checkoutReturnLineAmount,
49
+ totalQuantity,
50
+ t,
51
+ cancellationPolicyFee,
52
+ hasSelectedCancellationPolicy,
53
+ effectiveCancellationPolicyLabel,
54
+ feeLineItemsWithAddOns,
55
+ feeLineItems,
56
+ suppressSelfServeCurrencyUi,
57
+ selfServePricingConfirmed,
58
+ latestServerPriceSummaryLines,
59
+ quantities,
60
+ }: UseChangeBookingSummaryLinesParams) {
61
+ const checkoutPriceSummaryLines = useMemo((): PriceSummaryLine[] => {
62
+ if (!selectedAvailability) return [];
63
+ const returnLineAmount = checkoutReturnLineAmount;
64
+ const showReturnLine =
65
+ Boolean(selectedReturnOption) && Math.abs(returnLineAmount) > 0.0005;
66
+ return [
67
+ ...ticketLineItemsForChangeFlowDisplay.map((line): PriceSummaryLine => {
68
+ const rate = pricing.find((r) => r.category === line.category);
69
+ const breakdown = getPriceBreakdown(
70
+ line.category,
71
+ rate?.priceCAD ?? 0,
72
+ rate?.baseInDisplayCurrency,
73
+ rate?.appliedAdjustments ?? [],
74
+ );
75
+ return {
76
+ kind: 'ticket',
77
+ category: line.category,
78
+ qty: line.qty,
79
+ itemTotal: line.itemTotal,
80
+ breakdown,
81
+ };
82
+ }),
83
+ ...(showReturnLine
84
+ ? [
85
+ {
86
+ kind: 'line' as const,
87
+ label: `${t('booking.returnOption')} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`,
88
+ amount: returnLineAmount,
89
+ type: 'return',
90
+ },
91
+ ]
92
+ : []),
93
+ ...(cancellationPolicyFee > 0 && hasSelectedCancellationPolicy
94
+ ? [
95
+ {
96
+ kind: 'line' as const,
97
+ label: effectiveCancellationPolicyLabel,
98
+ amount: cancellationPolicyFee,
99
+ type: 'cancellation',
100
+ },
101
+ ]
102
+ : []),
103
+ ...feeLineItemsWithAddOns.map((fee) => {
104
+ const isMoraineLakeRoadAccessFee =
105
+ fee.name.toLowerCase().includes('moraine') &&
106
+ (fee.name.toLowerCase().includes('access') ||
107
+ fee.name.toLowerCase().includes('road') ||
108
+ fee.name.toLowerCase().includes('license'));
109
+ return {
110
+ kind: 'line' as const,
111
+ label: feeLineItems.some((f) => f.name === fee.name)
112
+ ? `${fee.name} (${totalQuantity} ${totalQuantity === 1 ? 'person' : 'people'})`
113
+ : fee.name,
114
+ amount: fee.totalAmount,
115
+ type: 'fee',
116
+ tooltip: isMoraineLakeRoadAccessFee
117
+ ? "Since 2025, Parks Canada charges a per-trip fee for License of Occupation. Based on our capacity, this per-person fee contributes towards Parks Canada's Moraine Lake Road operations."
118
+ : undefined,
119
+ };
120
+ }),
121
+ ];
122
+ }, [
123
+ selectedAvailability,
124
+ ticketLineItemsForChangeFlowDisplay,
125
+ pricing,
126
+ getPriceBreakdown,
127
+ selectedReturnOption,
128
+ checkoutReturnLineAmount,
129
+ totalQuantity,
130
+ t,
131
+ cancellationPolicyFee,
132
+ hasSelectedCancellationPolicy,
133
+ effectiveCancellationPolicyLabel,
134
+ feeLineItemsWithAddOns,
135
+ feeLineItems,
136
+ ]);
137
+
138
+ const checkoutPriceSummaryLinesForCheckout = useMemo(() => {
139
+ let raw: PriceSummaryLine[];
140
+ if (suppressSelfServeCurrencyUi && selfServePricingConfirmed) {
141
+ raw =
142
+ latestServerPriceSummaryLines && latestServerPriceSummaryLines.length > 0
143
+ ? mergePickerTicketQtyIntoPriceSummaryLines(latestServerPriceSummaryLines, quantities)
144
+ : checkoutPriceSummaryLines;
145
+ } else {
146
+ raw = checkoutPriceSummaryLines;
147
+ }
148
+ return omitZeroAmountPromoDiscountSummaryLines(raw);
149
+ }, [
150
+ suppressSelfServeCurrencyUi,
151
+ selfServePricingConfirmed,
152
+ checkoutPriceSummaryLines,
153
+ latestServerPriceSummaryLines,
154
+ quantities,
155
+ ]);
156
+
157
+ const priceSummaryLinesIncludeTaxRow = useMemo(
158
+ () =>
159
+ checkoutPriceSummaryLinesForCheckout.some(
160
+ (line) => line.kind === 'line' && String(line.type ?? '').toUpperCase() === 'TAX',
161
+ ),
162
+ [checkoutPriceSummaryLinesForCheckout],
163
+ );
164
+
165
+ return {
166
+ checkoutPriceSummaryLinesForCheckout,
167
+ priceSummaryLinesIncludeTaxRow,
168
+ };
169
+ }
@@ -0,0 +1,513 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import type {
3
+ AddOn,
4
+ Availability,
5
+ PrecomputedPricesByCategory,
6
+ PricingConfig,
7
+ Product,
8
+ } from '../../lib/booking-api';
9
+ import type { OrderSummaryFeeLine } from '../../lib/booking/pricing';
10
+ import type { ProviderDashboardChangeBookingPayload } from './provider-dashboard-change-booking';
11
+ import type { Currency } from './CurrencySwitcher';
12
+ import type { PriceSummaryLine } from './PriceSummary';
13
+ import {
14
+ applyResourceAdjustments,
15
+ getPrivateShuttleAvailabilityOptionId,
16
+ getResourceRate,
17
+ roundMoney,
18
+ } from './private-shuttle-availability';
19
+ import { usePrivateShuttlePromoDiscount } from './usePrivateShuttlePromoDiscount';
20
+
21
+ export const PRIVATE_SHUTTLE_RESOURCE_CAPACITY = 13;
22
+ export const PRIVATE_SHUTTLE_ADDITIONAL_HOUR_PRICE = 170;
23
+ export const MORAINE_LAKE_ROAD_ACCESS_FEE_TOOLTIP =
24
+ "Since 2025, Parks Canada charges a per-trip fee for License of Occupation at Moraine Lake. This fee contributes towards Parks Canada's Moraine Lake Road operations.";
25
+
26
+ type AddOnSelection = { addOnId: string; variantId?: string; quantity?: number };
27
+ type PrivateShuttleDepositConfig = {
28
+ percentage?: number;
29
+ fixedAmount?: number;
30
+ };
31
+
32
+ export function isMoraineLakeRoadAccessFeeName(name: string): boolean {
33
+ const normalized = name.toLowerCase();
34
+ return (
35
+ normalized.includes('moraine') &&
36
+ (normalized.includes('access') ||
37
+ normalized.includes('road') ||
38
+ normalized.includes('license'))
39
+ );
40
+ }
41
+
42
+ function isMoraineLakeLocationText(value: string): boolean {
43
+ const normalized = value.toLowerCase().replace(/[_-]+/g, ' ');
44
+ return normalized.includes('moraine') && normalized.includes('lake');
45
+ }
46
+
47
+ interface UsePrivateShuttlePriceSummaryParams {
48
+ selectedOption: string;
49
+ selectedDate: string;
50
+ selectedStartTime: string;
51
+ selectedAvailability: Availability | null;
52
+ selectedOptionConfig: Product['options'][number] | undefined;
53
+ activeOptions: Product['options'];
54
+ availabilitiesByDate: Record<string, Availability[]>;
55
+ resourcePriceByOption: Record<string, Record<string, number>> | null | undefined;
56
+ resourcePriceByCurrency: Record<string, number> | null | undefined;
57
+ precomputedPrices: PrecomputedPricesByCategory | null | undefined;
58
+ passengerCount: number;
59
+ currency: Currency;
60
+ pricingConfig: PricingConfig | null;
61
+ cancellationPolicyId: string | null;
62
+ draftItineraryDestinations: string[];
63
+ itineraryDestinations: NonNullable<Product['itineraryBuilder']>['destinations'] | undefined;
64
+ addOnSelections: AddOnSelection[];
65
+ addOns: AddOn[];
66
+ isAdmin: boolean;
67
+ additionalHoursCount: number;
68
+ activePromoCode: string | null;
69
+ productCompanyId?: string | null;
70
+ productId: string;
71
+ depositConfig?: PrivateShuttleDepositConfig;
72
+ t: (key: string) => string;
73
+ }
74
+
75
+ export function usePrivateShuttlePriceSummary({
76
+ selectedOption,
77
+ selectedDate,
78
+ selectedStartTime,
79
+ selectedAvailability,
80
+ selectedOptionConfig,
81
+ activeOptions,
82
+ availabilitiesByDate,
83
+ resourcePriceByOption,
84
+ resourcePriceByCurrency,
85
+ precomputedPrices,
86
+ passengerCount,
87
+ currency,
88
+ pricingConfig,
89
+ cancellationPolicyId,
90
+ draftItineraryDestinations,
91
+ itineraryDestinations,
92
+ addOnSelections,
93
+ addOns,
94
+ isAdmin,
95
+ additionalHoursCount,
96
+ activePromoCode,
97
+ productCompanyId,
98
+ productId,
99
+ depositConfig,
100
+ t,
101
+ }: UsePrivateShuttlePriceSummaryParams) {
102
+ const resourceCount = Math.ceil(passengerCount / PRIVATE_SHUTTLE_RESOURCE_CAPACITY);
103
+ const maxVacancies = selectedAvailability?.vacancies || 0;
104
+ const isSpecialRequest = passengerCount > maxVacancies;
105
+ const billableResourceCount = isSpecialRequest
106
+ ? Math.max(1, Math.ceil(maxVacancies / PRIVATE_SHUTTLE_RESOURCE_CAPACITY))
107
+ : resourceCount;
108
+
109
+ const getOptionPrice = useCallback(
110
+ (optionId: string) => {
111
+ const option = activeOptions.find((candidate) => candidate.optionId === optionId);
112
+ const dateAvailabilities = selectedDate ? availabilitiesByDate[selectedDate] || [] : [];
113
+ const availability = dateAvailabilities.find(
114
+ (candidate) => getPrivateShuttleAvailabilityOptionId(candidate) === optionId,
115
+ );
116
+ const profileOptionPrice = resourcePriceByOption?.[optionId]?.[currency];
117
+ if (profileOptionPrice != null) {
118
+ return applyResourceAdjustments(profileOptionPrice, availability, currency);
119
+ }
120
+ if (currency === 'CAD' && option?.pricing?.['RESOURCE'] != null) {
121
+ return applyResourceAdjustments(option.pricing['RESOURCE'], availability, currency);
122
+ }
123
+ if (availability?.rates) {
124
+ const resourceRate = getResourceRate(availability);
125
+ if (resourceRate?.priceByCurrency?.[currency] != null) {
126
+ return resourceRate.priceByCurrency[currency];
127
+ }
128
+ if (currency === 'CAD' && resourceRate?.price != null) return resourceRate.price;
129
+ }
130
+ if (currency === 'CAD' && resourcePriceByCurrency?.[currency] != null) {
131
+ return resourcePriceByCurrency[currency];
132
+ }
133
+ return option?.pricing?.['RESOURCE'] ?? 0;
134
+ },
135
+ [
136
+ activeOptions,
137
+ selectedDate,
138
+ availabilitiesByDate,
139
+ resourcePriceByOption,
140
+ resourcePriceByCurrency,
141
+ currency,
142
+ ],
143
+ );
144
+
145
+ const resourcePrice = useMemo(() => {
146
+ const selectedProfileOptionPrice = selectedOption
147
+ ? resourcePriceByOption?.[selectedOption]?.[currency]
148
+ : undefined;
149
+ if (selectedProfileOptionPrice != null) {
150
+ return applyResourceAdjustments(selectedProfileOptionPrice, selectedAvailability, currency);
151
+ }
152
+ if (currency === 'CAD' && selectedOptionConfig?.pricing?.['RESOURCE'] != null) {
153
+ return applyResourceAdjustments(
154
+ selectedOptionConfig.pricing['RESOURCE'],
155
+ selectedAvailability,
156
+ currency,
157
+ );
158
+ }
159
+ if (selectedAvailability?.rates) {
160
+ const resourceRate = getResourceRate(selectedAvailability);
161
+ if (resourceRate) {
162
+ const fromRate =
163
+ resourceRate.priceByCurrency?.[currency] ??
164
+ (currency === 'CAD' ? resourceRate.price : undefined);
165
+ if (fromRate != null) return fromRate;
166
+ }
167
+ }
168
+ if (currency === 'CAD' && resourcePriceByCurrency?.[currency] != null) {
169
+ return resourcePriceByCurrency[currency];
170
+ }
171
+ if (precomputedPrices?.['RESOURCE']?.[currency] != null) {
172
+ return precomputedPrices['RESOURCE'][currency];
173
+ }
174
+ if (selectedOptionConfig?.pricing?.['RESOURCE'] != null) {
175
+ return selectedOptionConfig.pricing['RESOURCE'];
176
+ }
177
+ return 0;
178
+ }, [
179
+ selectedOption,
180
+ selectedAvailability,
181
+ selectedOptionConfig,
182
+ resourcePriceByOption,
183
+ resourcePriceByCurrency,
184
+ precomputedPrices,
185
+ currency,
186
+ ]);
187
+
188
+ const basePrice = resourcePrice * resourceCount;
189
+ const isTaxIncludedInPrice = (pricingConfig?.currenciesWithTaxIncluded ?? []).includes(currency);
190
+ const taxRate = pricingConfig?.taxRate ?? 0.05;
191
+ const selectedCancellationPolicy = useMemo(
192
+ () => pricingConfig?.cancellationPolicies?.find((policy) => policy.id === cancellationPolicyId),
193
+ [pricingConfig?.cancellationPolicies, cancellationPolicyId],
194
+ );
195
+ const cancellationPolicyFee = selectedCancellationPolicy
196
+ ? (selectedCancellationPolicy.feeByCurrency[currency] ?? 0)
197
+ : 0;
198
+
199
+ const draftItineraryIncludesMoraineLake = useMemo(() => {
200
+ if (draftItineraryDestinations.length === 0) return false;
201
+ const destinationLabelsById = new Map(
202
+ (itineraryDestinations ?? []).map((destination) => [
203
+ destination.id,
204
+ destination.label,
205
+ ]),
206
+ );
207
+ return draftItineraryDestinations.some((destinationId) => {
208
+ const label = destinationLabelsById.get(destinationId);
209
+ return (
210
+ isMoraineLakeLocationText(destinationId) ||
211
+ (label ? isMoraineLakeLocationText(label) : false)
212
+ );
213
+ });
214
+ }, [draftItineraryDestinations, itineraryDestinations]);
215
+
216
+ const perBookingFeeLineItems = useMemo<OrderSummaryFeeLine[]>(() => {
217
+ if (isTaxIncludedInPrice) return [];
218
+ const amountsByName = pricingConfig?.perBookingFeesByCurrency?.[currency];
219
+ if (!amountsByName) return [];
220
+ const feeMetadata = pricingConfig?.perBookingFees ?? {};
221
+ return Object.entries(amountsByName)
222
+ .filter(
223
+ ([name]) =>
224
+ draftItineraryIncludesMoraineLake || !isMoraineLakeRoadAccessFeeName(name),
225
+ )
226
+ .map(([name, amount]) => ({
227
+ name,
228
+ totalAmount: amount,
229
+ description: feeMetadata[name]?.description,
230
+ }));
231
+ }, [currency, draftItineraryIncludesMoraineLake, isTaxIncludedInPrice, pricingConfig]);
232
+
233
+ const addOnTotal = useMemo(() => {
234
+ let sum = 0;
235
+ for (const selection of addOnSelections) {
236
+ const addOn = addOns.find((candidate) => candidate.addOnId === selection.addOnId);
237
+ if (!addOn) continue;
238
+ const base = addOn.price ?? 0;
239
+ const hasVariant =
240
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
241
+ selection.variantId;
242
+ const adjustment = hasVariant
243
+ ? (addOn.variants?.find((variant) => variant.id === selection.variantId)?.priceAdjustment ?? 0)
244
+ : 0;
245
+ sum += (base + adjustment) * (selection.quantity ?? 1);
246
+ }
247
+ return sum;
248
+ }, [addOnSelections, addOns]);
249
+
250
+ const additionalHoursAmount =
251
+ (isAdmin ? additionalHoursCount : 0) * PRIVATE_SHUTTLE_ADDITIONAL_HOUR_PRICE;
252
+ const perBookingFeeTotal = perBookingFeeLineItems.reduce(
253
+ (sum, fee) => sum + fee.totalAmount,
254
+ 0,
255
+ );
256
+ const subtotal = basePrice + addOnTotal + additionalHoursAmount + perBookingFeeTotal;
257
+
258
+ const {
259
+ promoDiscountAmount,
260
+ isGiftCard,
261
+ isVoucher,
262
+ effectivePromoDiscountAmount,
263
+ resetPromoDiscount,
264
+ } = usePrivateShuttlePromoDiscount({
265
+ activePromoCode,
266
+ selectedOption,
267
+ selectedDate,
268
+ selectedStartTime,
269
+ resourceCount,
270
+ productCompanyId,
271
+ productId,
272
+ currency,
273
+ subtotal,
274
+ });
275
+
276
+ const taxAmount = isTaxIncludedInPrice ? 0 : subtotal * taxRate;
277
+ const effectiveTaxAmount =
278
+ effectivePromoDiscountAmount > 0 && !isGiftCard && !isVoucher
279
+ ? (isTaxIncludedInPrice ? 0 : (subtotal - effectivePromoDiscountAmount) * taxRate)
280
+ : taxAmount;
281
+ const totalPrice = subtotal + effectiveTaxAmount - effectivePromoDiscountAmount;
282
+
283
+ const providerChangeAuthoritativeReceipt = useMemo<
284
+ ProviderDashboardChangeBookingPayload['authoritativeReceipt']
285
+ >(() => {
286
+ const lineItems: NonNullable<
287
+ ProviderDashboardChangeBookingPayload['authoritativeReceipt']
288
+ >['lineItems'] = [
289
+ {
290
+ type: 'TICKET',
291
+ label: resourceCount > 1 ? 'Shuttles' : 'Shuttle',
292
+ amount: roundMoney(basePrice),
293
+ quantity: resourceCount,
294
+ },
295
+ ];
296
+
297
+ for (const selection of addOnSelections) {
298
+ const addOn = addOns.find((candidate) => candidate.addOnId === selection.addOnId);
299
+ if (!addOn) continue;
300
+ const hasVariant =
301
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
302
+ selection.variantId;
303
+ const variant = hasVariant
304
+ ? addOn.variants?.find((candidate) => candidate.id === selection.variantId)
305
+ : null;
306
+ const amount =
307
+ ((addOn.price ?? 0) + (variant?.priceAdjustment ?? 0)) * (selection.quantity ?? 1);
308
+ lineItems.push({
309
+ type: 'FEE',
310
+ label: variant?.label ? `${addOn.name} (${variant.label})` : addOn.name,
311
+ amount: roundMoney(amount),
312
+ });
313
+ }
314
+
315
+ if (additionalHoursAmount > 0) {
316
+ lineItems.push({
317
+ type: 'FEE',
318
+ label:
319
+ additionalHoursCount === 1
320
+ ? 'Additional hour'
321
+ : `Additional hours (${additionalHoursCount})`,
322
+ amount: roundMoney(additionalHoursAmount),
323
+ });
324
+ }
325
+
326
+ for (const fee of perBookingFeeLineItems) {
327
+ lineItems.push({
328
+ type: 'FEE',
329
+ label: fee.name,
330
+ amount: roundMoney(fee.totalAmount),
331
+ });
332
+ }
333
+
334
+ if (cancellationPolicyFee > 0 && selectedCancellationPolicy) {
335
+ lineItems.push({
336
+ type: 'CANCELLATION_UPGRADE',
337
+ label: selectedCancellationPolicy.label,
338
+ amount: roundMoney(cancellationPolicyFee),
339
+ });
340
+ }
341
+
342
+ if (effectiveTaxAmount > 0) {
343
+ lineItems.push({
344
+ type: 'TAX',
345
+ label: t('booking.tax') !== 'booking.tax' ? t('booking.tax') : 'Taxes and fees',
346
+ amount: roundMoney(effectiveTaxAmount),
347
+ });
348
+ }
349
+
350
+ if (effectivePromoDiscountAmount > 0) {
351
+ lineItems.push({
352
+ type: isGiftCard ? 'GIFT_CARD' : 'PROMO_CODE',
353
+ label: activePromoCode ? `Promo: ${activePromoCode}` : (t('booking.discount') || 'Discount'),
354
+ amount: roundMoney(-effectivePromoDiscountAmount),
355
+ });
356
+ }
357
+
358
+ const expectedTotal = roundMoney(totalPrice);
359
+ const lineTotal = roundMoney(lineItems.reduce((sum, line) => sum + line.amount, 0));
360
+ const roundingDelta = roundMoney(expectedTotal - lineTotal);
361
+ if (Math.abs(roundingDelta) >= 0.01) {
362
+ lineItems.push({
363
+ type: 'ROUNDING',
364
+ label: t('booking.rounding') || 'Rounding',
365
+ amount: roundingDelta,
366
+ });
367
+ }
368
+
369
+ return {
370
+ currency,
371
+ lineItems,
372
+ subtotalBeforeTax: roundMoney(expectedTotal - roundMoney(effectiveTaxAmount)),
373
+ taxAmount: roundMoney(effectiveTaxAmount),
374
+ totalAmount: expectedTotal,
375
+ };
376
+ }, [
377
+ activePromoCode,
378
+ addOnSelections,
379
+ addOns,
380
+ additionalHoursAmount,
381
+ additionalHoursCount,
382
+ basePrice,
383
+ cancellationPolicyFee,
384
+ currency,
385
+ effectivePromoDiscountAmount,
386
+ effectiveTaxAmount,
387
+ isGiftCard,
388
+ perBookingFeeLineItems,
389
+ resourceCount,
390
+ selectedCancellationPolicy,
391
+ t,
392
+ totalPrice,
393
+ ]);
394
+
395
+ const depositInfo = useMemo(() => {
396
+ if (!depositConfig || totalPrice <= 0) return null;
397
+ let depositAmount = 0;
398
+ if (depositConfig.percentage != null) {
399
+ const percentage = depositConfig.percentage;
400
+ depositAmount =
401
+ percentage <= 1 ? totalPrice * percentage : (totalPrice * percentage) / 100;
402
+ } else if (depositConfig.fixedAmount != null) {
403
+ depositAmount = depositConfig.fixedAmount;
404
+ }
405
+ if (depositAmount <= 0) return null;
406
+ return {
407
+ depositAmount,
408
+ balanceAmount: totalPrice - depositAmount,
409
+ totalPrice,
410
+ };
411
+ }, [depositConfig, totalPrice]);
412
+
413
+ const priceSummaryLines = useMemo<PriceSummaryLine[]>(() => {
414
+ const addOnLines = addOnSelections
415
+ .map((selection): PriceSummaryLine | null => {
416
+ const addOn = addOns.find((candidate) => candidate.addOnId === selection.addOnId);
417
+ if (!addOn) return null;
418
+ const base = addOn.price ?? 0;
419
+ const hasVariant =
420
+ (addOn.variantType === 'single_choice' || addOn.variantType === 'multi_quantity') &&
421
+ selection.variantId;
422
+ const adjustment = hasVariant
423
+ ? (addOn.variants?.find((variant) => variant.id === selection.variantId)?.priceAdjustment ?? 0)
424
+ : 0;
425
+ return {
426
+ kind: 'line',
427
+ label: addOn.name,
428
+ amount: (base + adjustment) * (selection.quantity ?? 1),
429
+ type: 'fee',
430
+ };
431
+ })
432
+ .filter((line): line is PriceSummaryLine => line != null);
433
+
434
+ return [
435
+ {
436
+ kind: 'line',
437
+ label: resourceCount > 1 ? `Shuttle x ${resourceCount}` : 'Shuttle',
438
+ amount: basePrice,
439
+ type: 'ticket',
440
+ },
441
+ ...addOnLines,
442
+ ...(additionalHoursAmount > 0
443
+ ? [
444
+ {
445
+ kind: 'line' as const,
446
+ label:
447
+ additionalHoursCount === 1
448
+ ? 'Additional hour'
449
+ : `Additional hours (${additionalHoursCount})`,
450
+ amount: additionalHoursAmount,
451
+ type: 'fee',
452
+ },
453
+ ]
454
+ : []),
455
+ ...perBookingFeeLineItems.map((fee): PriceSummaryLine => ({
456
+ kind: 'line',
457
+ label: fee.name,
458
+ amount: fee.totalAmount,
459
+ type: 'fee',
460
+ tooltip: isMoraineLakeRoadAccessFeeName(fee.name)
461
+ ? MORAINE_LAKE_ROAD_ACCESS_FEE_TOOLTIP
462
+ : undefined,
463
+ })),
464
+ ...(cancellationPolicyFee > 0
465
+ ? [
466
+ {
467
+ kind: 'line' as const,
468
+ label: selectedCancellationPolicy?.label ?? 'Cancellation',
469
+ amount: cancellationPolicyFee,
470
+ type: 'cancellation',
471
+ },
472
+ ]
473
+ : []),
474
+ ];
475
+ }, [
476
+ addOnSelections,
477
+ addOns,
478
+ additionalHoursAmount,
479
+ additionalHoursCount,
480
+ basePrice,
481
+ cancellationPolicyFee,
482
+ perBookingFeeLineItems,
483
+ resourceCount,
484
+ selectedCancellationPolicy?.label,
485
+ ]);
486
+
487
+ return {
488
+ resourceCount,
489
+ maxVacancies,
490
+ isSpecialRequest,
491
+ billableResourceCount,
492
+ getOptionPrice,
493
+ resourcePrice,
494
+ basePrice,
495
+ isTaxIncludedInPrice,
496
+ taxRate,
497
+ selectedCancellationPolicy,
498
+ cancellationPolicyFee,
499
+ addOnTotal,
500
+ additionalHoursAmount,
501
+ perBookingFeeLineItems,
502
+ subtotal,
503
+ promoDiscountAmount,
504
+ isGiftCard,
505
+ effectivePromoDiscountAmount,
506
+ effectiveTaxAmount,
507
+ totalPrice,
508
+ providerChangeAuthoritativeReceipt,
509
+ depositInfo,
510
+ priceSummaryLines,
511
+ resetPromoDiscount,
512
+ };
513
+ }