@ticketboothapp/booking 1.2.180 → 1.2.182

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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/booking/AdminChangeBookingFlow.tsx +12 -7
  3. package/src/components/booking/BookingDialog.tsx +9 -4
  4. package/src/components/booking/BookingProductGrid.module.css +22 -10
  5. package/src/components/booking/BookingProductGrid.tsx +2 -2
  6. package/src/components/booking/ChangeBookingDialog.tsx +3 -1
  7. package/src/components/booking/ChangeBookingFlow.tsx +1 -1
  8. package/src/components/booking/ChangeBookingSelectionControlsPanel.tsx +3 -0
  9. package/src/components/booking/ChangeBookingTicketsAndAddOnsPanel.tsx +3 -0
  10. package/src/components/booking/NewBookingFlow.tsx +6 -2
  11. package/src/components/booking/PrivateShuttleAddOnsSection.tsx +1 -1
  12. package/src/components/booking/PrivateShuttleBookingFlow.tsx +21 -1
  13. package/src/components/booking/StandardBookingSelectionControlsPanel.tsx +9 -2
  14. package/src/components/booking/TicketSelector.module.css +8 -0
  15. package/src/components/booking/TicketSelector.tsx +8 -0
  16. package/src/components/booking/admin-change-flow-state-helpers.ts +35 -0
  17. package/src/components/booking/availability-cache-policy.ts +10 -0
  18. package/src/components/booking/booking-flow-types.ts +3 -0
  19. package/src/components/booking/booking-flow-ui.ts +10 -0
  20. package/src/components/booking/use-private-shuttle-availability.ts +5 -1
  21. package/src/components/booking/use-standard-booking-availability.ts +36 -10
  22. package/src/constants/pill-values.ts +0 -8
  23. package/src/constants/products.ts +2 -2
  24. package/src/data/product-descriptions/private-tour.en.json +1 -2
  25. package/src/index.ts +5 -0
  26. package/src/lib/booking/i18n/messages/en.json +1 -0
  27. package/src/lib/booking/i18n/messages/fr.json +1 -0
  28. package/src/lib/booking/partner-pricing-profile.ts +74 -0
  29. package/src/lib/booking/reservation-attempt.ts +138 -0
  30. package/src/lib/booking-api.ts +297 -71
  31. package/src/lib/env.ts +13 -0
  32. package/src/providers/booking-dialog-provider.tsx +3 -2
  33. package/src/public-partners.ts +12 -1
  34. package/src/runtime/types.ts +4 -0
  35. package/src/strings/en.json +1 -2
  36. package/src/strings/es.json +1 -2
  37. package/src/strings/fr.json +1 -2
  38. package/test/change-booking-helpers.test.ts +181 -1
  39. package/test/partner-pricing-profile.test.ts +46 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.180",
3
+ "version": "1.2.182",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -18,7 +18,6 @@ import { CURRENCIES, DEFAULT_CURRENCY, type Currency } from './CurrencySwitcher'
18
18
  import { useCompanyTimezone } from '../../contexts/CompanyContext';
19
19
  import { useBookingApp } from '../../contexts/BookingAppContext';
20
20
  import { useBookingHost } from '../../runtime';
21
- import { filterAvailabilitiesAfterCurrentTime } from '../../lib/booking/booking-cutoffs';
22
21
  import type { ChangeBookingFlowProps } from './booking-flow-types';
23
22
  import {
24
23
  normalizeAddOnSelections,
@@ -26,6 +25,7 @@ import {
26
25
  } from './change-booking-flow-helpers';
27
26
  import {
28
27
  buildChangeBookingMinimumQuantities,
28
+ filterAdminChangeCalendarAvailabilities,
29
29
  isAdminManualOverrideEligibleLine,
30
30
  resolveChangeFlowOriginalDate,
31
31
  resolveInitialChangeProductOptionId,
@@ -156,6 +156,10 @@ export function AdminChangeBookingFlow({
156
156
  isAdmin,
157
157
  companyId: env.COMPANY_ID,
158
158
  });
159
+ const changeFlowOriginalDate = useMemo(
160
+ () => resolveChangeFlowOriginalDate(initialValues?.dateTime, companyTimezone),
161
+ [initialValues?.dateTime, companyTimezone],
162
+ );
159
163
  const [adminAvailabilityNowMs, setAdminAvailabilityNowMs] = useState(() => Date.now());
160
164
 
161
165
  useEffect(() => {
@@ -171,8 +175,13 @@ export function AdminChangeBookingFlow({
171
175
  (rows: Availability[]) =>
172
176
  product.productType === 'PRIVATE_SHUTTLE'
173
177
  ? rows
174
- : filterAvailabilitiesAfterCurrentTime(rows, adminAvailabilityNow),
175
- [adminAvailabilityNow, product.productType],
178
+ : filterAdminChangeCalendarAvailabilities({
179
+ availabilities: rows,
180
+ originalDate: changeFlowOriginalDate,
181
+ companyTimezone,
182
+ now: adminAvailabilityNow,
183
+ }),
184
+ [adminAvailabilityNow, changeFlowOriginalDate, companyTimezone, product.productType],
176
185
  );
177
186
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
178
187
  const [selectedReturnOption, setSelectedReturnOption] = useState<ReturnOption | null>(null);
@@ -279,10 +288,6 @@ export function AdminChangeBookingFlow({
279
288
  const [skipConfirmationCommunications, setSkipConfirmationCommunications] = useState(false);
280
289
  /** Admin only: disable all auto communications for this booking (provider dashboard). */
281
290
  const [disableAutoCommunications, setDisableAutoCommunications] = useState(false);
282
- const changeFlowOriginalDate = useMemo(
283
- () => resolveChangeFlowOriginalDate(initialValues?.dateTime, companyTimezone),
284
- [initialValues?.dateTime, companyTimezone],
285
- );
286
291
  const isProviderDashboardChange = Boolean(onChangeBooking);
287
292
  const useAdminFeAuthoritativeQuote = isAdmin && isProviderDashboardChange;
288
293
  /** Any change from an existing booking (public or provider). */
@@ -8,6 +8,7 @@ import { getProduct, type Product } from '../../lib/booking-api';
8
8
  import './booking-flow.css';
9
9
  import BookingProductGrid from './BookingProductGrid';
10
10
  import { BookingFlow } from './BookingFlow';
11
+ import { VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY } from './booking-flow-ui';
11
12
  import { PrivateShuttleBookingFlow } from './PrivateShuttleBookingFlow';
12
13
  import { BookingFlowPreview } from './BookingFlowPreview';
13
14
  import { useIsBookingLaunchLive } from '../../hooks/useIsBookingLaunchLive';
@@ -162,6 +163,7 @@ function BookFlowScreen({
162
163
  onComplete();
163
164
  }}
164
165
  isPartialLaunch={isPartialLaunch}
166
+ flowUi={{ ticketFinePrintByCategory: VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY }}
165
167
  />
166
168
  )}
167
169
  </div>
@@ -194,6 +196,9 @@ export default function BookingDialog() {
194
196
  const isLaunchLive = useIsBookingLaunchLive();
195
197
  const isPartialLaunch = !isLaunchLive;
196
198
  const currentScreen = stack[stack.length - 1];
199
+ const currentScreenType = currentScreen.type;
200
+ const currentBookFlowProductId =
201
+ currentScreenType === 'book-flow' ? currentScreen.productId : null;
197
202
  const [productName, setProductName] = useState<string | null>(null);
198
203
  const dialogRef = useRef<HTMLDivElement>(null);
199
204
 
@@ -209,18 +214,18 @@ export default function BookingDialog() {
209
214
  const contentRef = useRef<HTMLDivElement>(null);
210
215
 
211
216
  useEffect(() => {
212
- if (currentScreen.type === 'product-grid') {
217
+ if (currentScreenType === 'product-grid') {
213
218
  setProductName(null);
214
219
  }
215
- }, [currentScreen.type]);
220
+ }, [currentScreenType]);
216
221
 
217
222
  // Reset scroll when switching to book-flow so we don't appear partially scrolled
218
223
  // (product-grid restores its own scroll when returning from a product)
219
224
  useEffect(() => {
220
- if (currentScreen.type === 'book-flow' && contentRef.current) {
225
+ if (currentScreenType === 'book-flow' && contentRef.current) {
221
226
  contentRef.current.scrollTop = 0;
222
227
  }
223
- }, [currentScreen.type, currentScreen.type === 'book-flow' ? currentScreen.productId : null]);
228
+ }, [currentBookFlowProductId, currentScreenType]);
224
229
 
225
230
  const overlayRef = useRef<HTMLDivElement>(null);
226
231
 
@@ -49,7 +49,7 @@
49
49
 
50
50
  .grid {
51
51
  display: grid;
52
- grid-template-columns: 1fr 1fr;
52
+ grid-template-columns: 1fr;
53
53
  gap: 0.75rem;
54
54
  }
55
55
 
@@ -65,10 +65,11 @@
65
65
  transform: scale(1.05);
66
66
  }
67
67
 
68
+ /* Mobile: full-width landscape card (single column) */
68
69
  .tileImageContainer {
69
70
  position: relative;
70
71
  width: 100%;
71
- height: 280px;
72
+ height: 220px;
72
73
  overflow: hidden;
73
74
  }
74
75
 
@@ -328,31 +329,42 @@
328
329
  display: none;
329
330
  }
330
331
 
331
- .tileImageContainer {
332
- height: 280px;
333
- }
334
-
335
332
  .tileStartTime {
336
- font-size: 2.5rem;
333
+ font-size: 2rem;
337
334
  }
338
335
 
339
336
  .expandedInner {
340
337
  flex-direction: column;
338
+ min-height: 0;
341
339
  }
342
340
 
343
341
  .expandedImage {
344
- flex: 0 0 340px;
345
- min-height: 340px;
342
+ flex: 0 0 260px;
343
+ min-height: 260px;
346
344
  }
347
345
 
348
346
  .expandedVideo {
349
- min-height: 340px;
347
+ min-height: 260px;
350
348
  }
351
349
 
352
350
  .expandedContent {
353
351
  padding: 1rem;
354
352
  }
355
353
 
354
+ /* Keep the CTA above the fold: title → book button → description → pills */
355
+ .expandedTitle {
356
+ order: -2;
357
+ }
358
+
359
+ .expandedActions {
360
+ order: -1;
361
+ margin: 0;
362
+ }
363
+
364
+ .bookButton {
365
+ width: 100%;
366
+ }
367
+
356
368
  .expandedTags {
357
369
  left: 0.75rem;
358
370
  }
@@ -375,7 +375,7 @@ export default function BookingProductGrid({
375
375
  const expandedProduct =
376
376
  expandedIndex >= 0 ? products[expandedIndex] : null;
377
377
 
378
- // Column count for expand-in-place logic (2 mobile, 3 desktop; compact always 2)
378
+ // Column count for expand-in-place logic (1 mobile, 3 desktop; compact always 2)
379
379
  const [cols, setCols] = useState(compact ? 2 : 3);
380
380
  useEffect(() => {
381
381
  if (compact) {
@@ -383,7 +383,7 @@ export default function BookingProductGrid({
383
383
  return;
384
384
  }
385
385
  const mq = window.matchMedia('(min-width: 768px)');
386
- const update = () => setCols(mq.matches ? 3 : 2);
386
+ const update = () => setCols(mq.matches ? 3 : 1);
387
387
  update();
388
388
  mq.addEventListener('change', update);
389
389
  return () => mq.removeEventListener('change', update);
@@ -8,6 +8,7 @@ import { useBookingHost } from '../../runtime';
8
8
  import { formatCurrencyAmount, type Currency } from '../../lib/currency';
9
9
  import { CURRENCIES, DEFAULT_CURRENCY } from './CurrencySwitcher';
10
10
  import { ChangeBookingFlow } from './ChangeBookingFlow';
11
+ import { VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY } from './booking-flow-ui';
11
12
  import type { ChangeFlowSelectionPreview } from './booking-flow-types';
12
13
  import { useBookingSourceMetadataFromLocation } from '../../hooks/useBookingSourceMetadataFromLocation';
13
14
  import styles from './BookingDialog.module.css';
@@ -131,7 +132,7 @@ export default function ChangeBookingDialog({
131
132
  return () => {
132
133
  cancelled = true;
133
134
  };
134
- }, [isOpen, apiProductId, minimalProduct, staticProduct]);
135
+ }, [isOpen, apiProductId, env.COMPANY_ID, minimalProduct, staticProduct]);
135
136
 
136
137
  useEffect(() => {
137
138
  if (isOpen) {
@@ -448,6 +449,7 @@ export default function ChangeBookingDialog({
448
449
  }}
449
450
  initialValues={initialValues}
450
451
  onChangeFlowSelectionPreview={handleChangeFlowSelectionPreview}
452
+ flowUi={{ ticketFinePrintByCategory: VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY }}
451
453
  />
452
454
  </div>
453
455
  )}
@@ -329,7 +329,6 @@ export function ChangeBookingFlow({
329
329
 
330
330
  const {
331
331
  initialAddOnQtyByKey,
332
- initialAddOnMinQtyByKey,
333
332
  initialAddOnMinTotalByAddOnId,
334
333
  updateAddOnSelections,
335
334
  } = useChangeBookingAddOnFloorController({
@@ -953,6 +952,7 @@ export function ChangeBookingFlow({
953
952
  addOnSelections={addOnSelections}
954
953
  onAddOnSelectionsChange={updateAddOnSelections}
955
954
  minimumTotalByAddOnId={isCustomerSelfServeChange ? initialAddOnMinTotalByAddOnId : undefined}
955
+ ticketFinePrintByCategory={flowUi?.ticketFinePrintByCategory}
956
956
  />
957
957
 
958
958
  {/* Total and Checkout — shared PriceSummary component */}
@@ -50,6 +50,7 @@ export interface ChangeBookingSelectionControlsPanelProps {
50
50
  suppressUnitPrices?: boolean;
51
51
  suppressReturnPerPersonPrices?: boolean;
52
52
  suppressAddOnPrices?: boolean;
53
+ ticketFinePrintByCategory?: Record<string, string>;
53
54
  }
54
55
 
55
56
  export function ChangeBookingSelectionControlsPanel({
@@ -90,6 +91,7 @@ export function ChangeBookingSelectionControlsPanel({
90
91
  suppressUnitPrices = false,
91
92
  suppressReturnPerPersonPrices = false,
92
93
  suppressAddOnPrices = false,
94
+ ticketFinePrintByCategory,
93
95
  }: ChangeBookingSelectionControlsPanelProps) {
94
96
  return (
95
97
  <>
@@ -156,6 +158,7 @@ export function ChangeBookingSelectionControlsPanel({
156
158
  onAddOnSelectionsChange={onAddOnSelectionsChange}
157
159
  minimumTotalByAddOnId={minimumTotalByAddOnId}
158
160
  suppressAddOnPrices={suppressAddOnPrices}
161
+ ticketFinePrintByCategory={ticketFinePrintByCategory}
159
162
  />
160
163
  ) : null}
161
164
  </>
@@ -40,6 +40,7 @@ export interface ChangeBookingTicketsAndAddOnsPanelProps {
40
40
  minimumTotalByAddOnId?: Map<string, number>;
41
41
  suppressUnitPrices?: boolean;
42
42
  suppressAddOnPrices?: boolean;
43
+ ticketFinePrintByCategory?: Record<string, string>;
43
44
  }
44
45
 
45
46
  export function ChangeBookingTicketsAndAddOnsPanel({
@@ -69,6 +70,7 @@ export function ChangeBookingTicketsAndAddOnsPanel({
69
70
  minimumTotalByAddOnId,
70
71
  suppressUnitPrices = false,
71
72
  suppressAddOnPrices = false,
73
+ ticketFinePrintByCategory,
72
74
  }: ChangeBookingTicketsAndAddOnsPanelProps) {
73
75
  const incompatibleSelections = incompatibleAddOnSelections(
74
76
  addOnSelections,
@@ -101,6 +103,7 @@ export function ChangeBookingTicketsAndAddOnsPanel({
101
103
  minimumQuantities={minimumQuantities}
102
104
  ticketUnitFloorByCategory={applyReceiptPaidFloors ? ticketUnitFloorByCategory : undefined}
103
105
  suppressUnitPrices={suppressUnitPrices}
106
+ ticketFinePrintByCategory={ticketFinePrintByCategory}
104
107
  />
105
108
 
106
109
  {totalQuantity > 0 && addOns.length > 0 ? (
@@ -69,13 +69,14 @@ export function NewBookingFlow({
69
69
  bookingSourceAttribution,
70
70
  partnerPortalBooking = false,
71
71
  availabilityPricingProfileId,
72
+ availabilityPricingProfileOverrides,
72
73
  availabilityCancellationPolicyProfileId,
73
74
  }: NewBookingFlowProps) {
74
75
  const { env, analytics, catalog } = useBookingHost();
75
76
  const { t } = useTranslations();
76
77
  const { locale } = useLocale();
77
78
  const companyTimezone = useCompanyTimezone(); // Get timezone from context
78
- const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
79
+ const fallbackPricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
79
80
  const cancellationPolicyProfileIdForAvailabilities =
80
81
  (availabilityCancellationPolicyProfileId ?? '').trim() || null;
81
82
  const {
@@ -162,6 +163,7 @@ export function NewBookingFlow({
162
163
  setSelectedReturnOption,
163
164
  selectedDate,
164
165
  setSelectedDate,
166
+ pricingProfileIdForAvailabilities,
165
167
  loadingAvailabilities,
166
168
  isFetchingMoreAvailabilities,
167
169
  pricingConfig,
@@ -182,7 +184,8 @@ export function NewBookingFlow({
182
184
  isAdmin,
183
185
  companyTimezone,
184
186
  appliedPromoCode,
185
- pricingProfileIdForAvailabilities,
187
+ pricingProfileIdForAvailabilities: fallbackPricingProfileIdForAvailabilities,
188
+ pricingProfileOverridesForAvailabilities: availabilityPricingProfileOverrides,
186
189
  cancellationPolicyProfileIdForAvailabilities,
187
190
  bookingCutoffNow,
188
191
  bookingCutoffMinutes,
@@ -952,6 +955,7 @@ export function NewBookingFlow({
952
955
  returnVacancies={effectiveSelectedReturnVacancies}
953
956
  isSimplifiedPricingView={isSimplifiedPricingView}
954
957
  onQuantityChange={handleQuantityChange}
958
+ ticketFinePrintByCategory={flowUi?.ticketFinePrintByCategory}
955
959
  addOns={addOns}
956
960
  addOnSelections={addOnSelections}
957
961
  onAddOnSelectionsChange={updateAddOnSelections}
@@ -153,7 +153,7 @@ export function PrivateShuttleAddOnsSection({
153
153
  Food restrictions
154
154
  </label>
155
155
  <p className="mb-2 text-sm text-stone-500">
156
- Shuttle includes croissants, coffee, tea, hot chocolate, and trail snacks.
156
+ Shuttle includes coffee, tea, hot chocolate, and trail snacks. If you are adding on breakfast or lunch, please let us know if you have any dietary restrictions or allergies.
157
157
  </p>
158
158
  <textarea
159
159
  id="food-restrictions"
@@ -59,6 +59,10 @@ import {
59
59
  resolveInitialPrivateShuttlePassengerCount,
60
60
  } from './private-shuttle-passenger-count';
61
61
  import { privateShuttleHiddenLunchAddOnId } from './private-shuttle-lunch-visibility';
62
+ import {
63
+ resolvePartnerPricingProfileId,
64
+ type PartnerPricingProfileOverride,
65
+ } from '../../lib/booking/partner-pricing-profile';
62
66
 
63
67
  interface PrivateShuttleBookingFlowProps {
64
68
  product: Product;
@@ -85,6 +89,8 @@ interface PrivateShuttleBookingFlowProps {
85
89
  partnerPortalBooking?: boolean;
86
90
  /** When set (e.g. partner portal), get-availabilities requests this pricing profile from the API. */
87
91
  availabilityPricingProfileId?: string | null;
92
+ /** Month-specific partner profiles resolved against selectedDate. */
93
+ availabilityPricingProfileOverrides?: readonly PartnerPricingProfileOverride[];
88
94
  /** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
89
95
  availabilityCancellationPolicyProfileId?: string | null;
90
96
  initialValues?: {
@@ -117,6 +123,7 @@ export function PrivateShuttleBookingFlow({
117
123
  bookingSourceAttribution,
118
124
  partnerPortalBooking = false,
119
125
  availabilityPricingProfileId,
126
+ availabilityPricingProfileOverrides,
120
127
  availabilityCancellationPolicyProfileId,
121
128
  initialValues,
122
129
  initialBooking,
@@ -126,7 +133,7 @@ export function PrivateShuttleBookingFlow({
126
133
  const { t } = useTranslations();
127
134
  const { locale } = useLocale();
128
135
  const companyTimezone = useCompanyTimezone();
129
- const pricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
136
+ const fallbackPricingProfileIdForAvailabilities = (availabilityPricingProfileId ?? '').trim() || null;
130
137
  const cancellationPolicyProfileIdForAvailabilities =
131
138
  (availabilityCancellationPolicyProfileId ?? '').trim() || null;
132
139
  const {
@@ -145,6 +152,19 @@ export function PrivateShuttleBookingFlow({
145
152
  companyTimezone,
146
153
  )
147
154
  );
155
+ const pricingProfileIdForAvailabilities = useMemo(
156
+ () => resolvePartnerPricingProfileId(
157
+ fallbackPricingProfileIdForAvailabilities,
158
+ availabilityPricingProfileOverrides,
159
+ selectedDate || formatInTimeZone(new Date(), companyTimezone, 'yyyy-MM-dd'),
160
+ ),
161
+ [
162
+ availabilityPricingProfileOverrides,
163
+ companyTimezone,
164
+ fallbackPricingProfileIdForAvailabilities,
165
+ selectedDate,
166
+ ],
167
+ );
148
168
  const [selectedAvailability, setSelectedAvailability] = useState<Availability | null>(null);
149
169
  const [selectedOption, setSelectedOption] = useState<string>('');
150
170
  const [selectedStartTime, setSelectedStartTime] = useState<string>('');
@@ -65,6 +65,7 @@ export interface StandardBookingSelectionControlsPanelProps {
65
65
  returnVacancies: number | null;
66
66
  isSimplifiedPricingView: boolean;
67
67
  onQuantityChange: (category: string, delta: number) => void;
68
+ ticketFinePrintByCategory?: Record<string, string>;
68
69
  addOns: AddOn[];
69
70
  addOnSelections: AddOnSelection[];
70
71
  onAddOnSelectionsChange: AddOnSelectionsChangeHandler;
@@ -107,6 +108,7 @@ export function StandardBookingSelectionControlsPanel({
107
108
  returnVacancies,
108
109
  isSimplifiedPricingView,
109
110
  onQuantityChange,
111
+ ticketFinePrintByCategory,
110
112
  addOns,
111
113
  addOnSelections,
112
114
  onAddOnSelectionsChange,
@@ -142,9 +144,13 @@ export function StandardBookingSelectionControlsPanel({
142
144
  ) : null}
143
145
 
144
146
  {selectedBookingOptionsHydrating ? (
145
- <div className="flex items-center justify-center gap-3 rounded-lg border border-stone-200 bg-white p-4 text-stone-600">
147
+ <div
148
+ className="flex items-center justify-center gap-3 py-2 text-stone-600"
149
+ role="status"
150
+ aria-live="polite"
151
+ >
146
152
  <div className="booking-loading-spinner" aria-hidden />
147
- <div>{t('booking.loadingTimes')}</div>
153
+ <div>{t('booking.confirmingAvailability')}</div>
148
154
  </div>
149
155
  ) : null}
150
156
 
@@ -218,6 +224,7 @@ export function StandardBookingSelectionControlsPanel({
218
224
  onQuantityChange={onQuantityChange}
219
225
  minimumQuantities={undefined}
220
226
  ticketUnitFloorByCategory={undefined}
227
+ ticketFinePrintByCategory={ticketFinePrintByCategory}
221
228
  />
222
229
  ) : null}
223
230
 
@@ -49,6 +49,14 @@
49
49
  color: var(--booking-stone-500, #78716c);
50
50
  }
51
51
 
52
+ .finePrint {
53
+ margin-top: 0.25rem;
54
+ font-size: 0.7rem;
55
+ line-height: 1.3;
56
+ color: var(--booking-stone-500, #78716c);
57
+ max-width: 16rem;
58
+ }
59
+
52
60
  .priceStrikethrough {
53
61
  text-decoration: line-through;
54
62
  color: var(--booking-stone-400, #a8a29e);
@@ -48,6 +48,8 @@ interface TicketSelectorProps {
48
48
  ticketUnitFloorByCategory?: Map<string, number>;
49
49
  /** Hide unit/strike prices (customer change flow until server-backed pricing is shown). */
50
50
  suppressUnitPrices?: boolean;
51
+ /** Optional small fine print rendered under a ticket row, keyed by category (e.g. INFANT). */
52
+ ticketFinePrintByCategory?: Record<string, string>;
51
53
  }
52
54
 
53
55
  export function TicketSelector({
@@ -70,6 +72,7 @@ export function TicketSelector({
70
72
  minimumQuantities,
71
73
  ticketUnitFloorByCategory,
72
74
  suppressUnitPrices = false,
75
+ ticketFinePrintByCategory,
73
76
  }: TicketSelectorProps) {
74
77
  /** Party size vs tighter of pickup/return vacancies (parent passes min of both legs). */
75
78
  const isOverbookedTickets = totalQuantity > selectedVacancies;
@@ -166,6 +169,11 @@ export function TicketSelector({
166
169
  formatCurrencyAmount(effectiveUnitPrice, currency, locale as 'en' | 'fr')
167
170
  )}
168
171
  </p>
172
+ {ticketFinePrintByCategory?.[rate.category] && (
173
+ <p className={styles.finePrint}>
174
+ {ticketFinePrintByCategory[rate.category]}
175
+ </p>
176
+ )}
169
177
  </div>
170
178
  <div className={styles.controls}>
171
179
  <button
@@ -1,5 +1,6 @@
1
1
  import { formatInTimeZone } from 'date-fns-tz';
2
2
  import type { Availability, Product } from '../../lib/booking-api';
3
+ import { isAvailabilityAfterCurrentTime } from '../../lib/booking/booking-cutoffs';
3
4
  import {
4
5
  effectiveProductOptionIdForChangeFlow,
5
6
  normalizeProductOptionIdForChangeFlow,
@@ -45,6 +46,40 @@ export function resolveChangeFlowOriginalDate(
45
46
  }
46
47
  }
47
48
 
49
+ /**
50
+ * Admin changes may need to edit the return for a tour that is already in progress.
51
+ * Keep every availability on that booking's service date visible even after its
52
+ * start time passes, while retaining the normal "future starts only" rule for
53
+ * every other date.
54
+ */
55
+ export function filterAdminChangeCalendarAvailabilities({
56
+ availabilities,
57
+ originalDate,
58
+ companyTimezone,
59
+ now = new Date(),
60
+ }: {
61
+ availabilities: Availability[];
62
+ originalDate: string | null;
63
+ companyTimezone: string;
64
+ now?: Date;
65
+ }): Availability[] {
66
+ return availabilities.filter((availability) => {
67
+ if (originalDate) {
68
+ try {
69
+ const availabilityDate = formatInTimeZone(
70
+ parseAvailabilityDateTime(availability.dateTime),
71
+ companyTimezone,
72
+ 'yyyy-MM-dd',
73
+ );
74
+ if (availabilityDate === originalDate) return true;
75
+ } catch {
76
+ /* fall through to the normal time guard */
77
+ }
78
+ }
79
+ return isAvailabilityAfterCurrentTime(availability, now);
80
+ });
81
+ }
82
+
48
83
  export function buildChangeBookingMinimumQuantities({
49
84
  isCustomerSelfServeChange,
50
85
  isAdmin,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A covered cache range is revalidated only after the cache TTL expires.
3
+ * Reserve/checkout perform their own fresh validation before committing inventory.
4
+ */
5
+ export function shouldRevalidateAvailabilityCache(
6
+ cacheCoversRange: boolean,
7
+ isStale: boolean,
8
+ ): boolean {
9
+ return cacheCoversRange && isStale;
10
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  Product,
7
7
  } from '../../lib/booking-api';
8
8
  import type { BookingSourceMetadata } from '../../lib/booking/source-metadata';
9
+ import type { PartnerPricingProfileOverride } from '../../lib/booking/partner-pricing-profile';
9
10
  import type { Currency } from './CurrencySwitcher';
10
11
  import type { PriceSummaryLine } from './PriceSummary';
11
12
  import type { BookingFlowUiOptions } from './booking-flow-ui';
@@ -129,6 +130,8 @@ export interface BookingFlowBaseProps {
129
130
  partnerPortalBooking?: boolean;
130
131
  /** When set (e.g. partner portal), get-availabilities requests this pricing profile from the API. */
131
132
  availabilityPricingProfileId?: string | null;
133
+ /** Month-specific partner pricing profiles resolved against the selected booking date. */
134
+ availabilityPricingProfileOverrides?: readonly PartnerPricingProfileOverride[];
132
135
  /** When set (e.g. partner portal), get-availabilities filters cancellation policies by this profile. */
133
136
  availabilityCancellationPolicyProfileId?: string | null;
134
137
  /** Admin change-booking: available destination products for switching the booking to another product. */
@@ -69,8 +69,18 @@ export interface BookingFlowUiOptions {
69
69
  providerDashboardChangePricingUi?: ProviderDashboardChangePricingUi;
70
70
  /** Override the orange section heading above cancellation policy cards (e.g. photo-first shuttle flow). */
71
71
  cancellationPolicySectionLabel?: string;
72
+ /** Small fine print shown under a ticket category row, keyed by category (e.g. { INFANT: "..." }). */
73
+ ticketFinePrintByCategory?: Record<string, string>;
72
74
  }
73
75
 
76
+ /**
77
+ * Via Via default per-category ticket fine print, shown under the ticket row in the
78
+ * ticket selector. Surfaces that pass their own `flowUi` can override or omit it.
79
+ */
80
+ export const VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY: Record<string, string> = {
81
+ INFANT: 'You are required to bring your own safety seat for children under 40lbs (20kg).',
82
+ };
83
+
74
84
  /**
75
85
  * Baseline UX for embedded partner-style surfaces (partner portal Book tab, TicketBooth provider dashboard):
76
86
  * skip collage + tour description, auto-pick the earliest bookable calendar day and first time slot,
@@ -32,6 +32,7 @@ import {
32
32
  privateShuttleAvailabilityDateInZone,
33
33
  privateShuttleStartTimeAllowed,
34
34
  } from './private-shuttle-availability';
35
+ import { shouldRevalidateAvailabilityCache } from './availability-cache-policy';
35
36
  import { getStartOfCurrentDay } from './standard-booking-availability';
36
37
 
37
38
  interface UsePrivateShuttleAvailabilityParams {
@@ -364,7 +365,10 @@ export function usePrivateShuttleAvailability({
364
365
  );
365
366
  const isStale = availabilitiesCache?.isStale(cached) ?? false;
366
367
  if (cacheCoversRange) {
367
- shouldRevalidateCachedRange = true;
368
+ shouldRevalidateCachedRange = shouldRevalidateAvailabilityCache(
369
+ cacheCoversRange,
370
+ isStale,
371
+ );
368
372
  setAvailabilities(cached.availabilities);
369
373
  if (cached.pricingConfig) applyCachedPricingConfig(cached.pricingConfig);
370
374
  if (cached.precomputedPrices) setPrecomputedPrices(cached.precomputedPrices);