@ticketboothapp/booking 1.2.189 → 1.2.191

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 (50) hide show
  1. package/package.json +3 -1
  2. package/src/components/BackgroundVideo.tsx +6 -3
  3. package/src/components/booking/AddOnsSection.module.css +129 -1
  4. package/src/components/booking/AddOnsSection.tsx +21 -25
  5. package/src/components/booking/AdminChangeBookingContent.tsx +1 -0
  6. package/src/components/booking/AdminChangeBookingFlow.tsx +1 -0
  7. package/src/components/booking/AdminChangeReceiptComparison.module.css +16 -0
  8. package/src/components/booking/AdminChangeReceiptComparison.tsx +14 -4
  9. package/src/components/booking/BookingDialog.module.css +43 -0
  10. package/src/components/booking/BookingFlowCollage.tsx +43 -16
  11. package/src/components/booking/Calendar.module.css +132 -13
  12. package/src/components/booking/Calendar.tsx +46 -20
  13. package/src/components/booking/CancellationPolicySelector.module.css +33 -4
  14. package/src/components/booking/CancellationPolicySelector.tsx +30 -28
  15. package/src/components/booking/ChangeBookingDialog.tsx +116 -55
  16. package/src/components/booking/ChangeBookingFlow.tsx +2 -0
  17. package/src/components/booking/ChangeBookingItineraryPanel.tsx +3 -0
  18. package/src/components/booking/CheckoutForm.module.css +7 -0
  19. package/src/components/booking/CollapsibleAddOnItem.module.css +4 -3
  20. package/src/components/booking/ItineraryBox.module.css +14 -0
  21. package/src/components/booking/ItineraryBox.tsx +75 -7
  22. package/src/components/booking/MealDrinkAddOnSelector.tsx +14 -16
  23. package/src/components/booking/NewBookingFlow.tsx +1 -0
  24. package/src/components/booking/PickupLocationDialog.tsx +19 -23
  25. package/src/components/booking/PickupLocationSelector.tsx +20 -25
  26. package/src/components/booking/PriceSummary.module.css +19 -0
  27. package/src/components/booking/PriceSummary.tsx +10 -7
  28. package/src/components/booking/PrivateShuttlePassengerSection.tsx +7 -1
  29. package/src/components/booking/ReturnTimeSelector.module.css +23 -4
  30. package/src/components/booking/ReturnTimeSelector.tsx +5 -5
  31. package/src/components/booking/StandardBookingItineraryPanel.tsx +3 -0
  32. package/src/components/booking/TicketSelector.module.css +30 -3
  33. package/src/components/booking/TicketSelector.tsx +6 -6
  34. package/src/components/booking/TourDescription.module.css +6 -1
  35. package/src/components/booking/TourDescription.tsx +25 -16
  36. package/src/components/booking/change-booking-compare.module.css +154 -0
  37. package/src/components/booking/change-booking-compare.tsx +141 -1
  38. package/src/constants/images.ts +1 -1
  39. package/src/hooks/useIsBookingLaunchLive.ts +4 -1
  40. package/src/index.ts +11 -0
  41. package/src/lib/booking/i18n/messages/en.json +2 -0
  42. package/src/lib/booking/i18n/messages/fr.json +2 -0
  43. package/src/lib/booking/pickup-location-disclaimer.ts +67 -0
  44. package/src/lib/booking-api.ts +1 -0
  45. package/src/lib/booking-constants.ts +1 -1
  46. package/src/strings/en.json +140 -68
  47. package/src/strings/es.json +181 -68
  48. package/src/strings/fr.json +181 -68
  49. package/test/pickup-location-disclaimer.test.ts +44 -0
  50. package/CHANGE_BOOKING_BE_HANDOFF.md +0 -160
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.189",
3
+ "version": "1.2.191",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css",
@@ -12,6 +12,7 @@
12
12
  "exports": {
13
13
  ".": "./src/index.ts",
14
14
  "./background-video": "./src/components/BackgroundVideo.tsx",
15
+ "./terms-content": "./src/components/booking/DefaultTermsContent.tsx",
15
16
  "./booking-flow.css": "./src/components/booking/booking-flow.css",
16
17
  "./runtime": "./src/runtime/index.ts",
17
18
  "./contexts/booking-app-context": "./src/contexts/BookingAppContext.tsx",
@@ -21,6 +22,7 @@
21
22
  "./hooks/useBookingSourceMetadataFromLocation": "./src/hooks/useBookingSourceMetadataFromLocation.ts",
22
23
  "./hooks/useIsBookingLaunchLive": "./src/hooks/useIsBookingLaunchLive.ts",
23
24
  "./hooks/usePlacesAutocompleteSearch": "./src/hooks/usePlacesAutocompleteSearch.ts",
25
+ "./pickup-location-disclaimer": "./src/lib/booking/pickup-location-disclaimer.ts",
24
26
  "./viavia/catalog": "./src/constants/products.ts",
25
27
  "./viavia/images": "./src/constants/images.ts",
26
28
  "./viavia/product-descriptions": "./src/lib/product-descriptions.ts",
@@ -20,6 +20,7 @@ export interface BackgroundVideoProps
20
20
  poster?: string;
21
21
  preload?: 'auto' | 'metadata' | 'none';
22
22
  loading?: BackgroundVideoLoading;
23
+ viewportMargin?: string;
23
24
  className?: string;
24
25
  style?: CSSProperties;
25
26
  }
@@ -41,6 +42,7 @@ export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps
41
42
  poster,
42
43
  preload,
43
44
  loading = 'viewport',
45
+ viewportMargin = '300px 0px',
44
46
  className,
45
47
  style,
46
48
  autoPlay = true,
@@ -80,11 +82,11 @@ export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps
80
82
  if (entry.isIntersecting) setShouldLoad(true);
81
83
  setShouldPlay(entry.isIntersecting);
82
84
  },
83
- { rootMargin: '300px 0px' },
85
+ { rootMargin: viewportMargin },
84
86
  );
85
87
  observer.observe(container);
86
88
  return () => observer.disconnect();
87
- }, [loading]);
89
+ }, [loading, viewportMargin]);
88
90
 
89
91
  useEffect(() => {
90
92
  const video = videoRef.current;
@@ -100,6 +102,7 @@ export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps
100
102
  style={{ width: '100%', height: '100%', display: 'grid', ...style }}
101
103
  >
102
104
  <video
105
+ key={shouldLoad ? `${webm ?? ''}|${src}` : 'deferred'}
103
106
  {...videoProps}
104
107
  ref={setVideoRef}
105
108
  className="next-video-bg-video"
@@ -107,7 +110,7 @@ export const BackgroundVideo = forwardRef<HTMLVideoElement, BackgroundVideoProps
107
110
  muted={muted}
108
111
  loop={loop}
109
112
  playsInline={playsInline}
110
- poster={poster}
113
+ poster={shouldLoad ? poster : undefined}
111
114
  preload={shouldLoad ? (preload ?? (loading === 'eager' ? 'auto' : 'metadata')) : 'none'}
112
115
  style={{
113
116
  gridArea: '1 / 1',
@@ -1,7 +1,7 @@
1
1
  .section {
2
2
  padding-top: 1.5rem;
3
3
  margin-top: 1.5rem;
4
- border-top: 1px solid var(--booking-stone-200, #e7e5e4);
4
+ border-top: 1px solid var(--booking-stone-200, #e7e5e4) !important;
5
5
  }
6
6
 
7
7
  .items {
@@ -39,3 +39,131 @@
39
39
  line-height: 1.5;
40
40
  color: var(--booking-stone-500, #78716c);
41
41
  }
42
+
43
+ .optionRow {
44
+ display: grid;
45
+ grid-template-columns: minmax(0, 1fr) auto auto;
46
+ grid-template-rows: auto auto;
47
+ align-items: center;
48
+ gap: 0.25rem 0.75rem;
49
+ padding: 0.75rem;
50
+ background: var(--booking-stone-50, #fafaf9);
51
+ border-radius: 0.5rem;
52
+ }
53
+
54
+ .optionLabel {
55
+ grid-column: 1;
56
+ grid-row: 1;
57
+ min-width: 0;
58
+ font-size: 0.875rem;
59
+ color: var(--booking-stone-800, #292524);
60
+ }
61
+
62
+ .optionPrice {
63
+ grid-column: 2;
64
+ grid-row: 1 / 3;
65
+ align-self: center;
66
+ flex-shrink: 0;
67
+ font-size: 0.875rem;
68
+ color: var(--booking-stone-500, #78716c);
69
+ white-space: nowrap;
70
+ }
71
+
72
+ .optionControls {
73
+ grid-column: 3;
74
+ grid-row: 1 / 3;
75
+ align-self: center;
76
+ display: flex;
77
+ flex-shrink: 0;
78
+ align-items: center;
79
+ gap: 0.5rem;
80
+ }
81
+
82
+ .quantityButton {
83
+ border: 1px solid var(--booking-stone-300, #d6d3d1) !important;
84
+ }
85
+
86
+ .optionDescription {
87
+ grid-column: 1;
88
+ grid-row: 2;
89
+ min-width: 0;
90
+ font-size: 0.75rem;
91
+ font-weight: 400;
92
+ line-height: 1.375;
93
+ color: var(--booking-stone-500, #78716c);
94
+ }
95
+
96
+ .choiceRow {
97
+ display: grid;
98
+ grid-template-columns: minmax(0, 1fr) auto;
99
+ grid-template-rows: auto auto;
100
+ align-items: center;
101
+ gap: 0.25rem 0.75rem;
102
+ width: 100%;
103
+ padding: 0.75rem;
104
+ border-width: 2px !important;
105
+ border-radius: 0.5rem;
106
+ text-align: left;
107
+ transition: border-color 0.2s, background-color 0.2s;
108
+ }
109
+
110
+ .choiceLabel {
111
+ grid-column: 1;
112
+ grid-row: 1;
113
+ min-width: 0;
114
+ font-size: 0.875rem;
115
+ font-weight: 500;
116
+ color: var(--booking-stone-800, #292524);
117
+ }
118
+
119
+ .choicePrice {
120
+ grid-column: 2;
121
+ grid-row: 1 / 3;
122
+ align-self: center;
123
+ font-size: 0.875rem;
124
+ color: var(--booking-stone-500, #78716c);
125
+ white-space: nowrap;
126
+ }
127
+
128
+ .choiceDescription {
129
+ grid-column: 1;
130
+ grid-row: 2;
131
+ min-width: 0;
132
+ font-size: 0.75rem;
133
+ font-weight: 400;
134
+ line-height: 1.375;
135
+ color: var(--booking-stone-500, #78716c);
136
+ }
137
+
138
+ @media (max-width: 639px) {
139
+ .optionRow {
140
+ grid-template-columns: minmax(0, 1fr) auto;
141
+ grid-template-rows: auto auto auto;
142
+ }
143
+
144
+ .optionPrice {
145
+ grid-column: 1;
146
+ grid-row: 2;
147
+ justify-self: start;
148
+ }
149
+
150
+ .optionControls {
151
+ grid-column: 2;
152
+ grid-row: 1 / 3;
153
+ }
154
+
155
+ .optionDescription {
156
+ grid-column: 1 / -1;
157
+ grid-row: 3;
158
+ margin-top: 0.125rem;
159
+ }
160
+
161
+ .choiceDescription {
162
+ grid-column: 1 / -1;
163
+ margin-top: 0.125rem;
164
+ }
165
+
166
+ .choicePrice {
167
+ grid-row: 1;
168
+ }
169
+ }
@@ -94,7 +94,7 @@ export function AddOnsSection({
94
94
  });
95
95
  }}
96
96
  disabled={!canDecrement}
97
- className="h-8 w-8 rounded-full border border-stone-300 bg-white text-sm text-stone-600 hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
97
+ className={`${styles.quantityButton} h-8 w-8 rounded-full bg-white text-sm text-stone-600 hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50`}
98
98
  >
99
99
 
100
100
  </button>
@@ -107,7 +107,7 @@ export function AddOnsSection({
107
107
  return [...rest, { addOnId: addOn.addOnId, quantity: quantity + 1 }];
108
108
  });
109
109
  }}
110
- className="h-8 w-8 rounded-full border border-stone-300 bg-white text-sm text-stone-600 hover:bg-stone-50"
110
+ className={`${styles.quantityButton} h-8 w-8 rounded-full bg-white text-sm text-stone-600 hover:bg-stone-50`}
111
111
  >
112
112
  +
113
113
  </button>
@@ -159,19 +159,12 @@ export function AddOnsSection({
159
159
  const canDecrement = qty > 0 && currentTotalForAddOn > minTotalForAddOn;
160
160
  const price = (addOn.price ?? 0) + (variant.priceAdjustment ?? 0);
161
161
  return (
162
- <div key={variant.id} className="flex flex-wrap items-center gap-3 p-3 bg-stone-50 rounded-lg">
163
- <span className="min-w-0 flex-1">
164
- <span className="block text-sm text-stone-800">{variant.label}</span>
165
- {variant.description?.trim() ? (
166
- <span className="mt-0.5 block text-xs leading-snug text-stone-500">
167
- {variant.description.trim()}
168
- </span>
169
- ) : null}
170
- </span>
171
- <span className="text-sm text-stone-500 shrink-0">
162
+ <div key={variant.id} className={styles.optionRow}>
163
+ <span className={styles.optionLabel}>{variant.label}</span>
164
+ <span className={styles.optionPrice}>
172
165
  {suppressPrices ? '—' : `${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')} ea`}
173
166
  </span>
174
- <div className="flex items-center gap-2 shrink-0">
167
+ <div className={styles.optionControls}>
175
168
  <button
176
169
  type="button"
177
170
  onClick={() => {
@@ -193,7 +186,7 @@ export function AddOnsSection({
193
186
  });
194
187
  }}
195
188
  disabled={!canDecrement}
196
- className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
189
+ className={`${styles.quantityButton} h-8 w-8 rounded-full bg-white text-stone-600 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed text-sm`}
197
190
  >
198
191
 
199
192
  </button>
@@ -215,11 +208,16 @@ export function AddOnsSection({
215
208
  ];
216
209
  });
217
210
  }}
218
- className="h-8 w-8 rounded-full border border-stone-300 bg-white text-stone-600 hover:bg-stone-50 text-sm"
211
+ className={`${styles.quantityButton} h-8 w-8 rounded-full bg-white text-stone-600 hover:bg-stone-50 text-sm`}
219
212
  >
220
213
  +
221
214
  </button>
222
215
  </div>
216
+ {variant.description?.trim() ? (
217
+ <span className={styles.optionDescription}>
218
+ {variant.description.trim()}
219
+ </span>
220
+ ) : null}
223
221
  </div>
224
222
  );
225
223
  })}
@@ -261,23 +259,21 @@ export function AddOnsSection({
261
259
  ];
262
260
  });
263
261
  }}
264
- className={`flex items-center justify-between w-full p-3 rounded-lg border-2 text-left transition-colors ${
262
+ className={`${styles.choiceRow} ${
265
263
  isSelected
266
264
  ? 'border-emerald-500 bg-emerald-50'
267
265
  : 'border-stone-200 bg-stone-50 hover:border-stone-300'
268
266
  }`}
269
267
  >
270
- <span className="min-w-0 flex-1">
271
- <span className="block text-sm font-medium text-stone-800">{variant.label}</span>
272
- {variant.description?.trim() ? (
273
- <span className="mt-0.5 block text-xs font-normal leading-snug text-stone-500">
274
- {variant.description.trim()}
275
- </span>
276
- ) : null}
277
- </span>
278
- <span className="text-sm text-stone-500">
268
+ <span className={styles.choiceLabel}>{variant.label}</span>
269
+ <span className={styles.choicePrice}>
279
270
  {suppressPrices ? '—' : `+${formatCurrencyAmount(price, currency, locale as 'en' | 'fr')}`}
280
271
  </span>
272
+ {variant.description?.trim() ? (
273
+ <span className={styles.choiceDescription}>
274
+ {variant.description.trim()}
275
+ </span>
276
+ ) : null}
281
277
  </button>
282
278
  );
283
279
  })}
@@ -86,6 +86,7 @@ const calendarPropKeys = [
86
86
  ] as const;
87
87
 
88
88
  const itineraryPropKeys = [
89
+ 'productId',
89
90
  'selectedDate',
90
91
  'hideItineraryBox',
91
92
  'activeOptions',
@@ -1238,6 +1238,7 @@ export function AdminChangeBookingFlow({
1238
1238
  ? getCalendarEffectiveOutboundVacancies
1239
1239
  : undefined,
1240
1240
  hideItineraryBox, activeOptions,
1241
+ productId: product.productId,
1241
1242
  destinationsCount: product.destinations?.length ?? 0, pickupLocationsCount: product.pickupLocations?.length ?? 0,
1242
1243
  getItineraryItems: computeItineraryDisplay,
1243
1244
  ...{ isItinerarySticky, isMobile, showTooltip, selectedPickupLocation, itineraryRef },
@@ -0,0 +1,16 @@
1
+ @media (max-width: 639px) {
2
+ .receiptTitle {
3
+ font-size: 0.875rem !important;
4
+ line-height: 1.25rem !important;
5
+ }
6
+
7
+ .amountDueLabel {
8
+ font-size: 0.875rem !important;
9
+ line-height: 1.25rem !important;
10
+ }
11
+
12
+ .amountDueValue {
13
+ font-size: 1rem !important;
14
+ line-height: 1.25rem !important;
15
+ }
16
+ }
@@ -9,6 +9,7 @@ import type { Locale } from '../../lib/booking/i18n/config';
9
9
  import type { ChangeBookingFlowProps } from './booking-flow-types';
10
10
  import type { Currency } from './CurrencySwitcher';
11
11
  import { PriceSummary, type PriceSummaryLine } from './PriceSummary';
12
+ import styles from './AdminChangeReceiptComparison.module.css';
12
13
  import { collapseAdminPromoLines } from './admin-change-receipt-lines';
13
14
  import type { AdminCustomReceiptLine } from './useAdminCustomReceiptLines';
14
15
  import {
@@ -74,6 +75,8 @@ export interface AdminChangeReceiptComparisonProps {
74
75
  }) => void;
75
76
  preserveExistingReceipt?: boolean;
76
77
  onPreserveExistingReceiptChange?: (preserve: boolean) => void;
78
+ /** Use denser, wrapping receipt typography in the public mobile change flow. */
79
+ compactMobile?: boolean;
77
80
  }
78
81
 
79
82
  function normalizeLocale(locale: string): Locale {
@@ -132,6 +135,7 @@ export function AdminChangeReceiptComparison({
132
135
  onSetReceiptLineOverride,
133
136
  preserveExistingReceipt = false,
134
137
  onPreserveExistingReceiptChange,
138
+ compactMobile = false,
135
139
  }: AdminChangeReceiptComparisonProps) {
136
140
  const [overrideDrafts, setOverrideDrafts] = useState<Record<string, string>>({});
137
141
  useEffect(() => {
@@ -344,7 +348,9 @@ export function AdminChangeReceiptComparison({
344
348
  <div className="grid gap-4 md:grid-cols-2">
345
349
  <section className="min-w-0 overflow-visible rounded-lg border border-stone-200 bg-stone-50/70 p-3">
346
350
  <div className="mb-3 flex items-center justify-between gap-3">
347
- <h3 className="text-sm font-semibold text-stone-900">Existing purchase</h3>
351
+ <h3 className={`${compactMobile ? styles.receiptTitle : ''} text-sm font-semibold text-stone-900`}>
352
+ Existing purchase
353
+ </h3>
348
354
  <span className="shrink-0 text-xs font-medium text-stone-500">
349
355
  {formatCurrencyAmount(originalReceipt.total, originalCurrency, displayLocale)}
350
356
  </span>
@@ -358,6 +364,7 @@ export function AdminChangeReceiptComparison({
358
364
  taxAmount={hasTaxLine(existingLines) ? 0 : originalReceipt.tax}
359
365
  taxRate={taxRate}
360
366
  size="sm"
367
+ compactMobileTotal={compactMobile}
361
368
  t={t}
362
369
  />
363
370
  </section>
@@ -365,7 +372,7 @@ export function AdminChangeReceiptComparison({
365
372
  <section className="min-w-0 overflow-visible rounded-lg border border-sky-200 bg-sky-50/50 p-3">
366
373
  <div className="mb-3">
367
374
  <div>
368
- <h3 className="text-sm font-semibold text-stone-900">
375
+ <h3 className={`${compactMobile ? styles.receiptTitle : ''} text-sm font-semibold text-stone-900`}>
369
376
  {isProductFamilyConversion ? 'New purchase' : 'Current change'}
370
377
  </h3>
371
378
  {isProductFamilyConversion ? (
@@ -436,6 +443,7 @@ export function AdminChangeReceiptComparison({
436
443
  locale={displayLocale}
437
444
  subtotal={summarySubtotal}
438
445
  size="sm"
446
+ compactMobileTotal={compactMobile}
439
447
  t={t}
440
448
  lineAmountInputs={preserveExistingReceipt ? undefined : { ...quotedOverrideAmountInputs, ...overrideDrafts }}
441
449
  onLineAmountInputChange={preserveExistingReceipt ? undefined : handleOverrideChange}
@@ -538,8 +546,10 @@ export function AdminChangeReceiptComparison({
538
546
  ) : null}
539
547
 
540
548
  <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-stone-200 bg-stone-50 px-3 py-2">
541
- <span className="text-sm font-semibold text-stone-900">{amountDueLabel}</span>
542
- <span className={`text-lg font-semibold tabular-nums ${amountDueClass}`}>
549
+ <span className={`${compactMobile ? styles.amountDueLabel : ''} text-sm font-semibold text-stone-900`}>
550
+ {amountDueLabel}
551
+ </span>
552
+ <span className={`${compactMobile ? styles.amountDueValue : ''} text-lg font-semibold tabular-nums ${amountDueClass}`}>
543
553
  {!selectionChanged || displayedSettlementAmount == null
544
554
  ? '—'
545
555
  : formatCurrencyAmount(displayedSettlementAmount, currency, displayLocale)}
@@ -109,6 +109,49 @@
109
109
  padding: 1.25rem;
110
110
  }
111
111
 
112
+ .bookingCompareSticky {
113
+ position: sticky;
114
+ top: 0;
115
+ z-index: 10;
116
+ margin-bottom: 0.75rem;
117
+ width: 100%;
118
+ border-radius: 1rem;
119
+ padding: 0.5rem 0;
120
+ background: var(--light-orange-background);
121
+ }
122
+
123
+ .mobileCompareDigestContainer {
124
+ display: none;
125
+ }
126
+
127
+ @media (max-width: 767px) {
128
+ .bookingCompareSticky {
129
+ z-index: 20;
130
+ max-height: calc(90vh - 4.5rem);
131
+ max-height: calc(90dvh - 4.5rem);
132
+ overflow: hidden;
133
+ }
134
+
135
+ .mobileCompareDigestContainer {
136
+ display: block;
137
+ }
138
+
139
+ .bookingCompareDetails {
140
+ max-height: calc(90vh - 11rem);
141
+ max-height: calc(90dvh - 11rem);
142
+ overflow-y: auto;
143
+ overscroll-behavior: contain;
144
+ border-radius: 0 0 1rem 1rem;
145
+ padding: 0 0.25rem 0.25rem;
146
+ -webkit-overflow-scrolling: touch;
147
+ }
148
+
149
+ .bookingCompareDetails :global(.booking-change-summary-column) {
150
+ border-radius: 0 0 0.75rem 0.75rem;
151
+ background: transparent;
152
+ }
153
+ }
154
+
112
155
  @media (min-width: 768px) {
113
156
  .dialog {
114
157
  max-width: 960px;
@@ -4,6 +4,7 @@
4
4
  import { useState, useRef, useEffect, useCallback } from 'react';
5
5
  import { BackgroundVideo } from '../BackgroundVideo';
6
6
  import type { VideoSources } from '../../constants/products';
7
+ import type { BackgroundVideoLoading } from '../BackgroundVideo';
7
8
  import { resolvePublicAssetUrl, useBookingHost } from '../../runtime';
8
9
  import { useTranslations } from '../../lib/booking/i18n';
9
10
  import styles from './BookingFlowCollage.module.css';
@@ -17,6 +18,17 @@ export interface BookingFlowCollageProps {
17
18
  imageIds: string[];
18
19
  /** Alt text prefix for images (e.g. product name) */
19
20
  altPrefix?: string;
21
+ /** Descriptive alt text matching imageIds. Falls back to numbered labels. */
22
+ imageAlts?: string[];
23
+ /** Responsive slot widths for the one- and two-column gallery cells. */
24
+ imageSizes?: {
25
+ narrow: string;
26
+ wide: string;
27
+ };
28
+ /** Defer loading when the collage appears below the fold. */
29
+ videoLoading?: BackgroundVideoLoading;
30
+ /** Browser preload hint once the video is eligible to load. */
31
+ videoPreload?: 'auto' | 'metadata' | 'none';
20
32
  }
21
33
 
22
34
  const DEFAULT_VIDEO: VideoSources = {
@@ -24,25 +36,36 @@ const DEFAULT_VIDEO: VideoSources = {
24
36
  webm: '/videos/via-via-moraine-lake-tour-video.webm',
25
37
  };
26
38
 
39
+ // The first and fourth cells occupy one grid column; the middle cells occupy
40
+ // two. Accurate slot hints prevent small thumbnails from selecting full-width
41
+ // gallery assets while retaining enough resolution for high-density displays.
42
+ const NARROW_IMAGE_SIZES = '(max-width: 640px) 29vw, 300px';
43
+ const WIDE_IMAGE_SIZES = '(max-width: 640px) 59vw, 560px';
44
+ const DEFAULT_IMAGE_SIZES = {
45
+ narrow: NARROW_IMAGE_SIZES,
46
+ wide: WIDE_IMAGE_SIZES,
47
+ };
48
+
27
49
  export function BookingFlowCollage({
28
50
  video,
29
51
  videoPosterImageId,
30
52
  imageIds,
31
53
  altPrefix = 'Tour',
54
+ imageAlts,
55
+ imageSizes = DEFAULT_IMAGE_SIZES,
56
+ videoLoading = 'eager',
57
+ videoPreload = 'auto',
32
58
  }: BookingFlowCollageProps) {
33
59
  const { catalog, slots, env } = useBookingHost();
34
60
  const ViaViaImage = slots.Image;
35
61
  const ImageModal = slots.ImageModal;
36
62
  const videoSrc = video ?? DEFAULT_VIDEO;
37
- // Use long version in BookingFlow when available; fall back to short
38
- const videoForCollageRaw =
39
- videoSrc.longSrc && videoSrc.longWebm
40
- ? { src: videoSrc.longSrc, webm: videoSrc.longWebm }
41
- : { src: videoSrc.src, webm: videoSrc.webm };
42
- const videoForCollage = {
43
- src: resolvePublicAssetUrl(env.STATIC_ASSET_ORIGIN, videoForCollageRaw.src),
44
- webm: resolvePublicAssetUrl(env.STATIC_ASSET_ORIGIN, videoForCollageRaw.webm),
45
- };
63
+ // Use the MP4 here because these videos include an audio track, while the
64
+ // WebM variants are silent background-video encodes.
65
+ const videoForCollageSrc = resolvePublicAssetUrl(
66
+ env.STATIC_ASSET_ORIGIN,
67
+ videoSrc.longSrc ?? videoSrc.src,
68
+ );
46
69
  const posterUrl =
47
70
  videoPosterImageId && catalog.getImageUrl
48
71
  ? String(catalog.getImageUrl(videoPosterImageId))
@@ -91,10 +114,13 @@ export function BookingFlowCollage({
91
114
  const handleToggleMute = useCallback(() => {
92
115
  const el = videoRef.current;
93
116
  if (!el) return;
94
- el.muted = !el.muted;
117
+ const nextMuted = !el.muted;
118
+ el.muted = nextMuted;
119
+ setIsMuted(nextMuted);
95
120
  }, []);
96
121
 
97
- const imageItems = gridImages.map((id, i) => ({ id, alt: `${altPrefix} - ${i + 1}` }));
122
+ const getAlt = (i: number) => imageAlts?.[i] ?? `${altPrefix} - ${i + 1}`;
123
+ const imageItems = gridImages.map((id, i) => ({ id, alt: getAlt(i) }));
98
124
 
99
125
  return (
100
126
  <div className={`${styles.collage} ${!hasGridImages ? styles.videoOnly : ''}`}>
@@ -102,11 +128,11 @@ export function BookingFlowCollage({
102
128
  <div className={styles.videoWrapper}>
103
129
  <BackgroundVideo
104
130
  ref={videoRef}
105
- {...videoForCollage}
106
- loading="eager"
107
- preload="auto"
131
+ src={videoForCollageSrc}
132
+ loading={videoLoading}
133
+ preload={videoPreload}
108
134
  autoPlay
109
- muted
135
+ muted={isMuted}
110
136
  loop
111
137
  playsInline
112
138
  poster={posterUrl}
@@ -163,8 +189,9 @@ export function BookingFlowCollage({
163
189
  >
164
190
  <ViaViaImage
165
191
  imageId={id}
166
- alt={`${altPrefix} - ${i + 1}`}
192
+ alt={getAlt(i)}
167
193
  context="GALLERY"
194
+ sizes={i === 0 || i === 3 ? imageSizes.narrow : imageSizes.wide}
168
195
  />
169
196
  </button>
170
197
  ))}