@ticketboothapp/booking 1.2.166 → 1.2.168

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 (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingContent.tsx +15 -1
  3. package/src/components/booking/AdminChangeBookingFlow.tsx +274 -94
  4. package/src/components/booking/AdminChangeCheckoutPanel.tsx +49 -13
  5. package/src/components/booking/AdminChangePricingPanel.tsx +400 -134
  6. package/src/components/booking/AdminChangePromoEditor.tsx +97 -0
  7. package/src/components/booking/AdminChangeReceiptComparison.tsx +172 -147
  8. package/src/components/booking/ChangeBookingFlow.tsx +6 -3
  9. package/src/components/booking/ChangeBookingQuoteStatusPlaceholder.tsx +7 -2
  10. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +6 -0
  11. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +80 -0
  12. package/src/components/booking/NewBookingFlow.tsx +14 -2
  13. package/src/components/booking/PickupLocationSelector.module.css +22 -0
  14. package/src/components/booking/PickupLocationSelector.tsx +3 -3
  15. package/src/components/booking/PriceBreakdown.tsx +66 -15
  16. package/src/components/booking/PriceSummary.tsx +16 -0
  17. package/src/components/booking/PrivateShuttleBookingFlow.tsx +52 -14
  18. package/src/components/booking/PrivateShuttleCheckoutSection.tsx +95 -54
  19. package/src/components/booking/admin-adjustment-components.ts +44 -0
  20. package/src/components/booking/admin-adjustment-tax-behavior.ts +50 -0
  21. package/src/components/booking/admin-change-flow-state-helpers.ts +10 -0
  22. package/src/components/booking/admin-change-provider-payload.ts +12 -1
  23. package/src/components/booking/admin-change-quote-request-key.ts +41 -1
  24. package/src/components/booking/admin-change-receipt-lines.ts +49 -0
  25. package/src/components/booking/admin-change-v2-quote-request.ts +109 -0
  26. package/src/components/booking/admin-refund-decision-context.ts +122 -0
  27. package/src/components/booking/admin-refund-disposition.ts +71 -2
  28. package/src/components/booking/availability-date-selection.ts +16 -0
  29. package/src/components/booking/booking-flow-types.ts +4 -0
  30. package/src/components/booking/booking-flow.css +5 -0
  31. package/src/components/booking/change-booking-error-message.ts +137 -0
  32. package/src/components/booking/change-booking-flow-helpers.ts +77 -2
  33. package/src/components/booking/change-booking-quote-guards.ts +4 -1
  34. package/src/components/booking/change-booking-quote-state.ts +44 -1
  35. package/src/components/booking/incompatible-add-on-selections.ts +19 -0
  36. package/src/components/booking/private-shuttle-availability.ts +14 -0
  37. package/src/components/booking/private-shuttle-cancellation-policy.ts +9 -0
  38. package/src/components/booking/private-shuttle-checkout-controller.ts +5 -0
  39. package/src/components/booking/private-shuttle-provider-change-runner.ts +21 -0
  40. package/src/components/booking/private-shuttle-reservation-runner.ts +5 -3
  41. package/src/components/booking/provider-dashboard-change-booking.ts +18 -2
  42. package/src/components/booking/use-standard-booking-availability.ts +5 -2
  43. package/src/components/booking/useAdminChangeCheckoutController.ts +56 -1
  44. package/src/components/booking/useAdminChangeProductReset.ts +6 -0
  45. package/src/components/booking/useAdminChangeProtectedPricing.ts +4 -0
  46. package/src/components/booking/useAdminChangeQuoteDisplayState.tsx +8 -6
  47. package/src/components/booking/useAdminChangeQuotePreview.ts +146 -61
  48. package/src/components/booking/useAdminCustomReceiptLines.ts +171 -10
  49. package/src/components/booking/useAdminProviderPricingAdjustments.ts +50 -19
  50. package/src/components/booking/useBookingAvailabilityAddOns.ts +26 -4
  51. package/src/components/booking/useBookingPromoAndQuantityController.ts +16 -2
  52. package/src/components/booking/useChangeBookingAddOnFloorController.ts +15 -9
  53. package/src/components/booking/useChangeBookingAutoSelections.ts +60 -17
  54. package/src/components/booking/useChangeBookingInitialHydration.ts +10 -7
  55. package/src/components/booking/useChangeBookingQuotePreview.ts +3 -1
  56. package/src/components/booking/useChangeBookingSelectionDetails.ts +23 -17
  57. package/src/components/booking/useStandardBookingAutoSelections.ts +55 -7
  58. package/src/lib/booking/booking-cutoffs.ts +22 -0
  59. package/src/lib/booking/change-booking-server-preview.ts +92 -5
  60. package/src/lib/booking/change-flow-pricing.ts +12 -0
  61. package/src/lib/booking/i18n/messages/en.json +1 -0
  62. package/src/lib/booking/i18n/messages/fr.json +1 -0
  63. package/src/lib/booking-api.ts +76 -1
  64. package/test/change-booking-helpers.test.ts +1319 -7
  65. package/src/components/booking/useAdminChangePricingDebugPanel.tsx +0 -178
@@ -0,0 +1,122 @@
1
+ import type {
2
+ AdminAmendmentOperationSnapshot,
3
+ AdminAmendmentRefundDisposition,
4
+ } from '../../lib/booking-api';
5
+
6
+ export interface AdminRefundDecisionContext {
7
+ selectionKey: string;
8
+ operations: AdminAmendmentOperationSnapshot[];
9
+ allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
10
+ removedValueByOperationId: Record<string, number>;
11
+ }
12
+
13
+ interface MergeAdminRefundDecisionContextParams {
14
+ current: AdminRefundDecisionContext | null;
15
+ selectionKey: string;
16
+ operations: AdminAmendmentOperationSnapshot[];
17
+ allowedByOperationId: Record<string, AdminAmendmentRefundDisposition[]>;
18
+ removedValueByOperationId: Record<string, number>;
19
+ }
20
+
21
+ /**
22
+ * Refund-decision quote responses are incremental: after one operation is answered,
23
+ * the server can return only the operations that still need an answer. Keep all of
24
+ * the operations for the current booking selection so an answered operation does
25
+ * not disappear from the next request and get requested again.
26
+ */
27
+ export function mergeAdminRefundDecisionContext({
28
+ current,
29
+ selectionKey,
30
+ operations,
31
+ allowedByOperationId,
32
+ removedValueByOperationId,
33
+ }: MergeAdminRefundDecisionContextParams): AdminRefundDecisionContext | null {
34
+ const sameSelection = current?.selectionKey === selectionKey ? current : null;
35
+ if (!sameSelection && operations.length === 0) return null;
36
+
37
+ const operationsById = new Map<string, AdminAmendmentOperationSnapshot>();
38
+ for (const operation of sameSelection?.operations ?? []) {
39
+ operationsById.set(operation.operationId, operation);
40
+ }
41
+ for (const operation of operations) {
42
+ operationsById.set(operation.operationId, operation);
43
+ }
44
+
45
+ const relevantOperationIds = new Set(operationsById.keys());
46
+ const mergedAllowed = {
47
+ ...(sameSelection?.allowedByOperationId ?? {}),
48
+ ...allowedByOperationId,
49
+ };
50
+ const mergedRemovedValues = {
51
+ ...(sameSelection?.removedValueByOperationId ?? {}),
52
+ ...removedValueByOperationId,
53
+ };
54
+
55
+ return {
56
+ selectionKey,
57
+ operations: [...operationsById.values()],
58
+ allowedByOperationId: Object.fromEntries(
59
+ Object.entries(mergedAllowed).filter(([operationId]) =>
60
+ relevantOperationIds.has(operationId),
61
+ ),
62
+ ),
63
+ removedValueByOperationId: Object.fromEntries(
64
+ Object.entries(mergedRemovedValues).filter(([operationId]) =>
65
+ relevantOperationIds.has(operationId),
66
+ ),
67
+ ),
68
+ };
69
+ }
70
+
71
+ export function selectRefundDispositionsForOperations(
72
+ context: AdminRefundDecisionContext | null,
73
+ additionalOperations: AdminAmendmentOperationSnapshot[],
74
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
75
+ ): Record<string, AdminAmendmentRefundDisposition> {
76
+ const relevantOperationIds = new Set([
77
+ ...(context?.operations ?? []).map((operation) => operation.operationId),
78
+ ...additionalOperations.map((operation) => operation.operationId),
79
+ ]);
80
+ return Object.fromEntries(
81
+ Object.entries(dispositionsByOperationId).filter(([operationId]) =>
82
+ relevantOperationIds.has(operationId),
83
+ ),
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Staff removals default to a pending refund. The refund remains a separate,
89
+ * reviewed action; this only removes the required choice from the change form.
90
+ */
91
+ export function defaultPendingRefundDispositions(
92
+ operations: readonly AdminAmendmentOperationSnapshot[],
93
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
94
+ ): Record<string, AdminAmendmentRefundDisposition> {
95
+ if (operations.length === 0) return dispositionsByOperationId;
96
+
97
+ return operations.reduce<Record<string, AdminAmendmentRefundDisposition>>(
98
+ (next, operation) => {
99
+ next[operation.operationId] = 'PENDING_REFUND';
100
+ return next;
101
+ },
102
+ { ...dispositionsByOperationId },
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Omitting a disposition for a return-price treatment operation tells the server
108
+ * to retain the original paid return-price floor.
109
+ */
110
+ export function defaultKeepOriginalReturnPriceDispositions(
111
+ operations: readonly AdminAmendmentOperationSnapshot[],
112
+ dispositionsByOperationId: Record<string, AdminAmendmentRefundDisposition>,
113
+ ): Record<string, AdminAmendmentRefundDisposition> {
114
+ if (operations.length === 0) return dispositionsByOperationId;
115
+
116
+ const returnOperationIds = new Set(operations.map((operation) => operation.operationId));
117
+ return Object.fromEntries(
118
+ Object.entries(dispositionsByOperationId).filter(
119
+ ([operationId]) => !returnOperationIds.has(operationId),
120
+ ),
121
+ );
122
+ }
@@ -74,10 +74,79 @@ export function normalizeReturnPriceTreatment(
74
74
  : 'KEEP_FLOOR';
75
75
  }
76
76
 
77
- export function adminRemovalOperationLabel(operation: AdminAmendmentOperationSnapshot): string {
77
+ export type AdminRemovalOperationLabelContext = {
78
+ sourceProductName?: string | null;
79
+ targetProductName?: string | null;
80
+ promoCode?: string | null;
81
+ manualAdjustmentLabelsById?: Readonly<Record<string, string>>;
82
+ };
83
+
84
+ function conciseTourName(name: string): string {
85
+ const beforeTourDescription = name.split(/\s+Tour:\s*/i)[0]?.trim() || name.trim();
86
+ return beforeTourDescription.replace(/\s+Tour$/i, '').trim();
87
+ }
88
+
89
+ export function adminRemovalOperationLabel(
90
+ operation: AdminAmendmentOperationSnapshot,
91
+ context: AdminRemovalOperationLabelContext = {},
92
+ ): string {
93
+ if (operation.type === 'REVERSE_MANUAL_ADJUSTMENT') {
94
+ const adjustmentId = operation.componentKey.startsWith('manual_adjustment:')
95
+ ? operation.componentKey.slice('manual_adjustment:'.length)
96
+ : '';
97
+ const label = operation.metadata?.displayName?.trim() || (
98
+ adjustmentId
99
+ ? context.manualAdjustmentLabelsById?.[adjustmentId]?.trim()
100
+ : ''
101
+ );
102
+ return label ? `Remove adjustment: ${label}` : 'Remove existing adjustment';
103
+ }
104
+ if (
105
+ operation.type === 'ADD_MANUAL_ADJUSTMENT' &&
106
+ operation.componentKey.startsWith('manual_adjustment:promo_') &&
107
+ context.promoCode?.trim()
108
+ ) {
109
+ return `Apply promo to entire booking: ${context.promoCode.trim().toUpperCase()}`;
110
+ }
111
+ if (operation.type === 'ADD_MANUAL_ADJUSTMENT') {
112
+ const adjustmentId = operation.componentKey.startsWith('manual_adjustment:')
113
+ ? operation.componentKey.slice('manual_adjustment:'.length)
114
+ : '';
115
+ const label = adjustmentId
116
+ ? context.manualAdjustmentLabelsById?.[adjustmentId]?.trim()
117
+ : '';
118
+ if (label) return label;
119
+ }
120
+ if (
121
+ operation.type === 'MOVE_SERVICE' &&
122
+ context.sourceProductName?.trim() &&
123
+ context.targetProductName?.trim()
124
+ ) {
125
+ return `Change tour: ${conciseTourName(context.sourceProductName)} → ${conciseTourName(context.targetProductName)}`;
126
+ }
78
127
  const component = operation.componentKey.split(':').slice(1).join(':') || operation.componentKey;
79
128
  const quantity = operation.quantityDelta ? ` (${Math.abs(operation.quantityDelta)})` : '';
80
- return `${operation.type.replaceAll('_', ' ').toLowerCase()}: ${component}${quantity}`;
129
+ const operationName = operation.type.replaceAll('_', ' ').toLowerCase();
130
+ return `${operationName.charAt(0).toUpperCase()}${operationName.slice(1)}: ${component}${quantity}`;
131
+ }
132
+
133
+ export function refundTreatmentDisplayValue(
134
+ operation: AdminAmendmentOperationSnapshot,
135
+ operations: readonly AdminAmendmentOperationSnapshot[],
136
+ removedValueByOperationId: Readonly<Record<string, number>>,
137
+ refundCandidate: number,
138
+ ): number | undefined {
139
+ const removedValue = removedValueByOperationId[operation.operationId];
140
+ if (!Number.isFinite(removedValue)) return undefined;
141
+
142
+ // With one decision, the server's aggregate refund candidate is the exact amount
143
+ // that can be returned after first reducing any unpaid booking balance. Keep the
144
+ // gross removed value available to the settlement helpers, but do not present it
145
+ // to an operator as if the entire amount were refundable.
146
+ if (operations.length === 1 && Number.isFinite(refundCandidate)) {
147
+ return Math.min(Math.max(0, refundCandidate), Math.max(0, removedValue));
148
+ }
149
+ return removedValue;
81
150
  }
82
151
 
83
152
  export function refundDecisionsComplete(
@@ -0,0 +1,16 @@
1
+ import type { Availability } from '../../lib/booking-api';
2
+
3
+ export function findFirstDateWithBookableAvailability(
4
+ dates: string[],
5
+ getRowsForDate: (date: string) => Availability[],
6
+ isBookable: (availability: Availability) => boolean,
7
+ ): string | undefined {
8
+ return dates.find((date) => getRowsForDate(date).some(isBookable));
9
+ }
10
+
11
+ export function selectedDateHasVisibleAvailability(
12
+ selectedDate: string,
13
+ getRowsForDate: (date: string) => Availability[],
14
+ ): boolean {
15
+ return selectedDate !== '' && getRowsForDate(selectedDate).length > 0;
16
+ }
@@ -177,6 +177,10 @@ export interface ChangeBookingFlowProps extends BookingFlowBaseProps {
177
177
  label?: string;
178
178
  amount?: number;
179
179
  quantity?: number;
180
+ reference?: string | null;
181
+ identity?: {
182
+ componentId?: string | null;
183
+ } | null;
180
184
  priceBasis?: import('../../lib/booking-api').PriceBasisSnapshot | null;
181
185
  }>;
182
186
  } | null;
@@ -330,6 +330,11 @@
330
330
  padding: 0.5rem 0.85rem;
331
331
  min-height: 2.5rem;
332
332
  }
333
+ .booking-flow-preflight .admin-custom-receipt-apply {
334
+ box-sizing: border-box;
335
+ padding: 0.75rem 1.5rem;
336
+ min-height: 2.75rem;
337
+ }
333
338
 
334
339
  /* Labels */
335
340
  .booking-flow-preflight label {
@@ -0,0 +1,137 @@
1
+ const CHANGE_BOOKING_ERROR_MESSAGES: Record<string, string> = {
2
+ pricing_v2_unsupported_features:
3
+ 'Some selected booking options do not work together. Review the highlighted selections before continuing.',
4
+ pricing_v2_capacity_unavailable:
5
+ 'There is not enough availability for this change. Choose another time or reduce the number of guests.',
6
+ pricing_v2_availability_required:
7
+ 'Choose an available date and time before continuing.',
8
+ pricing_v2_availability_mismatch:
9
+ 'The selected date or time is no longer available for this tour. Choose another option and try again.',
10
+ pricing_v2_invalid_passenger_counts:
11
+ 'Review the number and types of guests before continuing.',
12
+ pricing_v2_product_option_not_found:
13
+ 'The selected tour option is no longer available. Refresh and choose another option.',
14
+ pricing_v2_price_not_available:
15
+ 'A confirmed price is not available for this selection. Choose another option or try again.',
16
+ pricing_v2_return_price_not_found:
17
+ 'A confirmed price is not available for the selected return. Choose another return time.',
18
+ pricing_v2_itinerary_not_available:
19
+ 'An itinerary is not available for this selection. Choose another date, time, or pickup location.',
20
+ pricing_v2_invalid_additional_hours:
21
+ 'Review the number of additional hours before continuing.',
22
+ pricing_v2_additional_hours_not_supported:
23
+ 'Additional hours are not available for the selected tour.',
24
+ pricing_v2_dependent_add_on_not_available:
25
+ 'A linked add-on is not available with this change. Review the linked booking before continuing.',
26
+ pricing_v2_dependent_add_on_currency_mismatch:
27
+ 'A linked add-on uses a different currency and cannot be changed with this booking.',
28
+ pricing_v2_invalid_admin_adjustment:
29
+ 'Review the custom pricing adjustment before continuing.',
30
+ pricing_v2_cancellation_policy_not_authorized:
31
+ 'The selected cancellation policy cannot be used for this booking.',
32
+ pricing_v2_cancellation_upgrade_fee_not_found:
33
+ 'A confirmed price is not available for the selected cancellation-policy upgrade.',
34
+ pricing_v2_pricing_profile_not_authorized:
35
+ 'The selected pricing option cannot be used for this booking.',
36
+ pricing_v2_non_no_change_not_implemented:
37
+ 'This combination of changes is not supported yet. Try changing one booking option at a time.',
38
+ pricing_v2_admin_amendment_payment_disabled:
39
+ 'Payments for booking changes are temporarily unavailable. Try again later.',
40
+ pricing_v2_admin_apply_disabled:
41
+ 'Saving booking changes is temporarily unavailable. Try again later.',
42
+ pricing_v2_admin_quote_disabled:
43
+ 'Pricing booking changes is temporarily unavailable. Try again later.',
44
+ apply_v2_not_enabled:
45
+ 'Booking changes are temporarily unavailable. Try again later.',
46
+ admin_refund_disposition_required:
47
+ 'Choose how to handle the removed paid value before continuing.',
48
+ admin_refund_disposition_not_supported:
49
+ 'The selected refund treatment is not available for this change.',
50
+ admin_refund_disposition_invalid:
51
+ 'Choose a valid refund treatment before continuing.',
52
+ amendment_refund_disposition_not_supported:
53
+ 'The selected refund treatment is not available for this change.',
54
+ amendment_quote_blocked:
55
+ 'This booking change cannot be completed with the selected options.',
56
+ admin_ledger_requires_review_or_repair:
57
+ 'This booking needs a financial review before it can be changed.',
58
+ amendment_ledger_summary_missing:
59
+ 'This booking needs a financial review before it can be changed.',
60
+ amendment_ledger_components_missing:
61
+ 'This booking needs a financial review before it can be changed.',
62
+ booking_amendment_ledger_pointer_mismatch:
63
+ 'This booking changed after the page was opened. Refresh it before trying again.',
64
+ promo_change_only_no_eligible_new_value:
65
+ 'This promo only applies to new charges. Make a priced change, or choose Entire active booking.',
66
+ structured_adjustment_basis_is_zero:
67
+ 'This credit needs a value to apply to. Make a priced booking change, or choose All active tickets, Entire active booking, or Selected components.',
68
+ };
69
+
70
+ function readableList(values: string[]): string {
71
+ if (values.length <= 1) return values[0] ?? '';
72
+ if (values.length === 2) return `${values[0]} and ${values[1]}`;
73
+ return `${values.slice(0, -1).join(', ')}, and ${values.at(-1)}`;
74
+ }
75
+
76
+ export function incompatibleAddOnChangeError(
77
+ addOnNames: readonly string[],
78
+ targetTourName: string | null | undefined,
79
+ ): string {
80
+ const names = Array.from(new Set(addOnNames.map((name) => name.trim()).filter(Boolean)));
81
+ const tour = targetTourName?.trim();
82
+ const itemLabel = readableList(names) || 'The selected add-on';
83
+ const availability = tour
84
+ ? `${itemLabel} ${names.length > 1 ? "aren't" : "isn't"} available with ${tour}.`
85
+ : `${itemLabel} ${names.length > 1 ? "aren't" : "isn't"} available with the selected tour.`;
86
+ const action = names.length > 1
87
+ ? 'Remove these add-ons or choose a different tour.'
88
+ : 'Remove the add-on or choose a different tour.';
89
+ return `${availability} ${action}`;
90
+ }
91
+
92
+ function errorCodeFromMessage(message: string): string | null {
93
+ const match = message.match(
94
+ /\b(?:pricing_v2|apply_v2|promo|admin|amendment|booking_amendment|quote|structured_adjustment)_[a-z0-9_]+/i,
95
+ );
96
+ return match?.[0]?.toLowerCase() ?? null;
97
+ }
98
+
99
+ /**
100
+ * Converts stable server reason codes into operator-facing copy while preserving
101
+ * messages that are already written for people.
102
+ */
103
+ export function friendlyChangeBookingError(message: string | null | undefined): string {
104
+ const trimmed = message?.trim() ?? '';
105
+ if (!trimmed) return '';
106
+
107
+ const code = errorCodeFromMessage(trimmed);
108
+ if (!code) return trimmed;
109
+
110
+ const exact = CHANGE_BOOKING_ERROR_MESSAGES[code];
111
+ if (exact) return exact;
112
+
113
+ if (code.startsWith('admin_adjustment_refund_disposition_required')) {
114
+ return 'Choose how to handle the removed adjustment value before continuing.';
115
+ }
116
+ if (
117
+ code.includes('expired') ||
118
+ code.includes('hash_mismatch') ||
119
+ code.includes('revision_mismatch') ||
120
+ code.includes('receipt_mismatch') ||
121
+ code.includes('status_mismatch') ||
122
+ code.includes('stale')
123
+ ) {
124
+ return 'This booking or price changed after the page was opened. Refresh it and try again.';
125
+ }
126
+ if (code.includes('capacity') || code.includes('unavailable')) {
127
+ return 'The selected booking option is no longer available. Choose another option and try again.';
128
+ }
129
+ if (code.includes('required') || code.includes('missing')) {
130
+ return 'More information is required before this booking change can be completed. Review the selections and try again.';
131
+ }
132
+ if (code.includes('invalid') || code.includes('mismatch')) {
133
+ return 'This booking change could not be verified. Refresh the booking and try again.';
134
+ }
135
+
136
+ return 'This booking change cannot be completed with the selected options. Review the selections and try again.';
137
+ }
@@ -95,6 +95,70 @@ export function resolveTicketQtyFromQuantities(
95
95
  return undefined;
96
96
  }
97
97
 
98
+ /**
99
+ * Carry the original ticket counts into an amendment only when the selected
100
+ * availability can price those categories. This prevents a private shuttle's
101
+ * hidden RESOURCE count from leaking into a regular-tour quote (and vice versa).
102
+ */
103
+ export function deriveCompatibleInitialBookingQuantities(
104
+ bookingItems: ReadonlyArray<{ category?: string | null; count?: number | null }>,
105
+ availability: Availability,
106
+ ): Record<string, number> {
107
+ const allowedCategories = new Map<string, string>();
108
+ for (const category of [
109
+ ...(availability.rates ?? []).map((rate) => rate.category),
110
+ ...(availability.pricesByCategory?.retailPrices ?? []).map((price) => price.category),
111
+ ]) {
112
+ const trimmed = category?.trim();
113
+ if (trimmed) allowedCategories.set(trimmed.toUpperCase(), trimmed);
114
+ }
115
+
116
+ const quantities: Record<string, number> = {};
117
+ for (const item of bookingItems) {
118
+ const sourceCategory = item.category?.trim();
119
+ if (!sourceCategory) continue;
120
+ const normalizedCategory = sourceCategory.toUpperCase();
121
+ const matchedCategory = allowedCategories.get(normalizedCategory);
122
+ if (allowedCategories.size > 0 && !matchedCategory) continue;
123
+ if (allowedCategories.size === 0 && availability.productType === 'STANDARD' && normalizedCategory === 'RESOURCE') {
124
+ continue;
125
+ }
126
+ if (allowedCategories.size === 0 && availability.productType === 'PRIVATE_SHUTTLE' && normalizedCategory !== 'RESOURCE') {
127
+ continue;
128
+ }
129
+ quantities[matchedCategory ?? sourceCategory] = Math.max(0, Number(item.count) || 0);
130
+ }
131
+ return quantities;
132
+ }
133
+
134
+ export function resolveChangeBookingCancellationPolicyId(
135
+ currentPolicyId: string | null | undefined,
136
+ policies: NonNullable<PricingConfig['cancellationPolicies']>,
137
+ currency: string,
138
+ forceTargetDefault: boolean,
139
+ ): string | null {
140
+ if (policies.length === 0) return null;
141
+ const current = currentPolicyId?.trim();
142
+ if (!forceTargetDefault && current && policies.some((policy) => policy.id === current)) {
143
+ return current;
144
+ }
145
+ return [...policies]
146
+ .sort((a, b) => (a.feeByCurrency[currency] ?? 0) - (b.feeByCurrency[currency] ?? 0))[0]
147
+ ?.id ?? null;
148
+ }
149
+
150
+ export function isProductFamilyReceiptConversion(
151
+ originalLines: PriceSummaryLine[],
152
+ resultingLines: PriceSummaryLine[],
153
+ ): boolean {
154
+ if (resultingLines.length === 0) return false;
155
+ const containsResourceTicket = (lines: PriceSummaryLine[]): boolean =>
156
+ lines.some(
157
+ (line) => line.kind === 'ticket' && line.category.trim().toUpperCase() === 'RESOURCE',
158
+ );
159
+ return containsResourceTicket(originalLines) !== containsResourceTicket(resultingLines);
160
+ }
161
+
98
162
  export function mergePickerTicketQtyIntoPriceSummaryLines(
99
163
  lines: PriceSummaryLine[],
100
164
  quantities: Record<string, number>,
@@ -632,7 +696,8 @@ export function resolveInitialAvailabilityFromBooking(
632
696
  bookingItems: Array<{ category: string; count: number }> | null | undefined,
633
697
  precomputedPricesByOption: Record<string, PrecomputedPricesByCategory> | null | undefined,
634
698
  currency: Currency,
635
- originalSubtotalBeforeTax: number | undefined
699
+ originalSubtotalBeforeTax: number | undefined,
700
+ preferredFallbackProductOptionId?: string | null
636
701
  ): { selection: Availability | null; defer: boolean } {
637
702
  const availId = initialAvailabilityId?.trim() || null;
638
703
  const optId = initialProductOptionId?.trim() || null;
@@ -719,7 +784,17 @@ export function resolveInitialAvailabilityFromBooking(
719
784
  }
720
785
  }
721
786
 
722
- const fallback = timesForSelectedDate.find((a) => a.vacancies > 0) ?? null;
787
+ const preferredFallback = preferredFallbackProductOptionId
788
+ ? timesForSelectedDate.find(
789
+ (availability) =>
790
+ availability.productOptionId === preferredFallbackProductOptionId &&
791
+ availability.vacancies > 0,
792
+ )
793
+ : null;
794
+ const fallback =
795
+ preferredFallback ??
796
+ timesForSelectedDate.find((availability) => availability.vacancies > 0) ??
797
+ null;
723
798
  return { selection: fallback, defer: false };
724
799
  }
725
800
 
@@ -5,6 +5,7 @@ import {
5
5
  roundMoney,
6
6
  type ChangeQuoteUiSlice,
7
7
  } from '../../lib/booking/change-flow-pricing';
8
+ import { friendlyChangeBookingError } from './change-booking-error-message';
8
9
 
9
10
  const CHANGE_PAYMENT_CONFIRMATION_ERROR =
10
11
  'This change requires payment, but the price could not be confirmed. Please refresh and try again.';
@@ -162,7 +163,9 @@ export function evaluateChangeBookingQuoteForCheckout(
162
163
  } = params;
163
164
 
164
165
  if (!quoteSlice.canProceed) {
165
- throw new Error(quote.reasonIfBlocked || 'This booking change cannot be completed right now.');
166
+ throw new Error(friendlyChangeBookingError(
167
+ quote.reasonIfBlocked || 'This booking change cannot be completed right now.',
168
+ ));
166
169
  }
167
170
 
168
171
  const signedBalanceMajor = resolveSignedBalanceMajor(quote);
@@ -1,4 +1,7 @@
1
- import type { ChangeBookingQuoteResponse } from '../../lib/booking-api';
1
+ import type {
2
+ ChangeBookingQuoteResponse,
3
+ ItineraryDisplayStep,
4
+ } from '../../lib/booking-api';
2
5
  import {
3
6
  type ChangeQuoteUiSlice,
4
7
  sliceChangeQuoteForUi,
@@ -11,10 +14,47 @@ import {
11
14
  buildChangeBookingServerPreview,
12
15
  } from '../../lib/booking/change-booking-server-preview';
13
16
  import type { Currency } from './CurrencySwitcher';
17
+ import { selectableAdminAdjustmentComponentsFromQuote } from './admin-adjustment-components';
14
18
 
15
19
  export type ChangeBookingServerPreview = ReturnType<typeof buildChangeBookingServerPreview>;
16
20
  export type ChangeBookingMergedQuoteState = ReturnType<typeof mergeQuoteSliceWithServerPreview>;
17
21
 
22
+ function isItineraryDisplayStep(value: unknown): value is ItineraryDisplayStep {
23
+ if (value == null || typeof value !== 'object') return false;
24
+ const step = value as Partial<ItineraryDisplayStep>;
25
+ return (
26
+ typeof step.stepType === 'string' &&
27
+ typeof step.time === 'string' &&
28
+ typeof step.place === 'string'
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Reads the canonical itinerary embedded in a Pricing V2 amendment quote.
34
+ *
35
+ * The server merges confirmed dependent add-on sessions into this itinerary, so change-booking
36
+ * comparison UIs must prefer it over a client-side product itinerary recomputation.
37
+ */
38
+ export function authoritativeItineraryDisplayFromChangeQuote(
39
+ quote: ChangeBookingQuoteResponse,
40
+ ): ItineraryDisplayStep[] | null {
41
+ const pricingQuote = quote.pricingQuote ?? quote.quote;
42
+ const rawItinerary = pricingQuote?.amendment?.targetState?.nonPriceState?.itinerary;
43
+ if (rawItinerary == null) return null;
44
+
45
+ let parsed: unknown = rawItinerary;
46
+ if (typeof rawItinerary === 'string') {
47
+ try {
48
+ parsed = JSON.parse(rawItinerary);
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ if (!Array.isArray(parsed) || !parsed.every(isItineraryDisplayStep)) return null;
55
+ return parsed.map((step) => ({ ...step }));
56
+ }
57
+
18
58
  export function mergeQuoteSliceWithServerPreview(
19
59
  slice: ChangeQuoteUiSlice,
20
60
  quote: ChangeBookingQuoteResponse,
@@ -25,8 +65,11 @@ export function mergeQuoteSliceWithServerPreview(
25
65
  ...slice,
26
66
  currency: (slice.currency || currency) as Currency,
27
67
  serverPreview: buildChangeBookingServerPreview(quote, fallbackCart, currency),
68
+ authoritativeItineraryDisplay: authoritativeItineraryDisplayFromChangeQuote(quote),
28
69
  pricingDriftDetail: normalizePricingDriftDetailFromQuote(quote),
29
70
  ticketPricingTrace: normalizeTicketPricingTraceFromQuote(quote),
71
+ selectableAdjustmentComponents: selectableAdminAdjustmentComponentsFromQuote(quote),
72
+ reversibleAdjustments: quote.reversibleAdjustments ?? [],
30
73
  /** Same cent pair the BE uses for `amountDueCents` / intent & receipt "New Booking Difference". */
31
74
  quotePreviousTotalCents: quote.previousTotalCents,
32
75
  quoteNewTotalCents: quote.newTotalCents,
@@ -0,0 +1,19 @@
1
+ import type { AddOn } from '../../lib/booking-api';
2
+ import type { AddOnSelection } from './AddOnsSection';
3
+
4
+ export function incompatibleAddOnSelections(
5
+ selections: AddOnSelection[],
6
+ availableAddOns: AddOn[],
7
+ catalogLoaded: boolean,
8
+ ): AddOnSelection[] {
9
+ if (!catalogLoaded) return [];
10
+ const availableIds = new Set(availableAddOns.map((addOn) => addOn.addOnId));
11
+ return selections.filter((selection) => !availableIds.has(selection.addOnId));
12
+ }
13
+
14
+ export function removeAddOnSelections(
15
+ selections: AddOnSelection[],
16
+ addOnId: string,
17
+ ): AddOnSelection[] {
18
+ return selections.filter((selection) => selection.addOnId !== addOnId);
19
+ }
@@ -20,6 +20,20 @@ export function parseAvailabilityDateTime(value: string): Date {
20
20
  return parseISO(hasExplicitOffset ? value : `${value}Z`);
21
21
  }
22
22
 
23
+ export function privateShuttleInitialDateKey(
24
+ value: string | null | undefined,
25
+ timezone: string,
26
+ ): string {
27
+ const trimmed = value?.trim();
28
+ if (!trimmed) return '';
29
+ if (DATE_ONLY_REGEX.test(trimmed)) return trimmed;
30
+ try {
31
+ return formatInTimeZone(parseAvailabilityDateTime(trimmed), timezone, 'yyyy-MM-dd');
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
23
37
  export function privateShuttleStartDateTime(dateStr: string, timeStr: string, timezone: string): Date {
24
38
  return fromZonedTime(parseISO(`${dateStr}T${timeStr}:00`), timezone);
25
39
  }
@@ -0,0 +1,9 @@
1
+ export function resolvePrivateShuttleCancellationPolicyId(
2
+ currentPolicyId: string | null | undefined,
3
+ availablePolicyIds: readonly string[],
4
+ ): string | null {
5
+ const validIds = availablePolicyIds.map((id) => id.trim()).filter(Boolean);
6
+ const current = currentPolicyId?.trim();
7
+ if (current && validIds.includes(current)) return current;
8
+ return validIds[0] ?? null;
9
+ }
@@ -309,7 +309,9 @@ export function usePrivateShuttleCheckoutController({
309
309
  if (isProviderChangeMode && onChangeBooking) {
310
310
  await applyPrivateShuttleProviderChange({
311
311
  onChangeBooking,
312
+ parentProductId: productId,
312
313
  selectedOption,
314
+ selectedAvailabilityId: selectedAvailability?.availabilityId ?? null,
313
315
  selectedDate,
314
316
  selectedStartTime,
315
317
  billableResourceCount,
@@ -325,6 +327,9 @@ export function usePrivateShuttleCheckoutController({
325
327
  activePromoCode,
326
328
  totalPrice,
327
329
  additionalHoursCount,
330
+ draftItineraryDestinations,
331
+ draftItineraryPlanningNotes,
332
+ itineraryDisplayItems,
328
333
  isAdmin,
329
334
  providerChangeAuthoritativeReceipt,
330
335
  });