@ticketboothapp/booking 1.2.190 → 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 (30) hide show
  1. package/package.json +3 -1
  2. package/src/components/BackgroundVideo.tsx +6 -3
  3. package/src/components/booking/AddOnsSection.module.css +6 -2
  4. package/src/components/booking/AddOnsSection.tsx +4 -4
  5. package/src/components/booking/AdminChangeReceiptComparison.module.css +16 -0
  6. package/src/components/booking/AdminChangeReceiptComparison.tsx +14 -4
  7. package/src/components/booking/BookingDialog.module.css +43 -0
  8. package/src/components/booking/BookingFlowCollage.tsx +43 -16
  9. package/src/components/booking/Calendar.module.css +8 -3
  10. package/src/components/booking/ChangeBookingDialog.tsx +116 -55
  11. package/src/components/booking/ChangeBookingFlow.tsx +1 -0
  12. package/src/components/booking/CheckoutForm.module.css +7 -0
  13. package/src/components/booking/CollapsibleAddOnItem.module.css +3 -2
  14. package/src/components/booking/MealDrinkAddOnSelector.tsx +4 -4
  15. package/src/components/booking/PickupLocationDialog.tsx +19 -23
  16. package/src/components/booking/PickupLocationSelector.tsx +20 -25
  17. package/src/components/booking/PriceSummary.module.css +19 -0
  18. package/src/components/booking/PriceSummary.tsx +10 -7
  19. package/src/components/booking/TourDescription.module.css +6 -1
  20. package/src/components/booking/TourDescription.tsx +25 -16
  21. package/src/components/booking/change-booking-compare.module.css +154 -0
  22. package/src/components/booking/change-booking-compare.tsx +141 -1
  23. package/src/constants/images.ts +1 -1
  24. package/src/index.ts +11 -0
  25. package/src/lib/booking/pickup-location-disclaimer.ts +67 -0
  26. package/src/lib/booking-api.ts +1 -0
  27. package/src/strings/en.json +140 -68
  28. package/src/strings/es.json +181 -68
  29. package/src/strings/fr.json +181 -68
  30. package/test/pickup-location-disclaimer.test.ts +44 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ticketboothapp/booking",
3
- "version": "1.2.190",
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 {
@@ -79,6 +79,10 @@
79
79
  gap: 0.5rem;
80
80
  }
81
81
 
82
+ .quantityButton {
83
+ border: 1px solid var(--booking-stone-300, #d6d3d1) !important;
84
+ }
85
+
82
86
  .optionDescription {
83
87
  grid-column: 1;
84
88
  grid-row: 2;
@@ -97,7 +101,7 @@
97
101
  gap: 0.25rem 0.75rem;
98
102
  width: 100%;
99
103
  padding: 0.75rem;
100
- border-width: 2px;
104
+ border-width: 2px !important;
101
105
  border-radius: 0.5rem;
102
106
  text-align: left;
103
107
  transition: border-color 0.2s, background-color 0.2s;
@@ -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>
@@ -186,7 +186,7 @@ export function AddOnsSection({
186
186
  });
187
187
  }}
188
188
  disabled={!canDecrement}
189
- 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`}
190
190
  >
191
191
 
192
192
  </button>
@@ -208,7 +208,7 @@ export function AddOnsSection({
208
208
  ];
209
209
  });
210
210
  }}
211
- 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`}
212
212
  >
213
213
  +
214
214
  </button>
@@ -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
  ))}
@@ -375,7 +375,12 @@
375
375
  transform: translateX(-50%);
376
376
  }
377
377
 
378
- .calendarGrid {
378
+ /*
379
+ * Keep the frame more specific than the scoped booking preflight
380
+ * (`.booking-flow-preflight *`), which otherwise wins when its stylesheet is
381
+ * emitted later and resets the border width to zero on some mobile builds.
382
+ */
383
+ .calendar .calendarGrid {
379
384
  border: 1px solid var(--cal-outer-border);
380
385
  border-radius: 0.5rem;
381
386
  overflow: hidden;
@@ -395,7 +400,7 @@
395
400
  border-bottom: 1px solid var(--cal-header-border);
396
401
  }
397
402
 
398
- .calendarHeaderCell {
403
+ .calendar .calendarHeaderCell {
399
404
  padding: 0.25rem 0;
400
405
  text-align: center;
401
406
  font-size: 0.75rem;
@@ -405,7 +410,7 @@
405
410
  letter-spacing: 0.05em;
406
411
  border-right: 1px solid var(--cal-header-border);
407
412
  }
408
- .calendarHeaderCell:last-child {
413
+ .calendar .calendarHeaderCell:last-child {
409
414
  border-right: none;
410
415
  }
411
416
 
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
5
  import { AnimatePresence, motion } from 'framer-motion';
6
6
  import { getProduct, type Product } from '../../lib/booking-api';
7
7
  import { useBookingHost } from '../../runtime';
8
- import { formatCurrencyAmount, type Currency } from '../../lib/currency';
8
+ import { formatCurrencyAmount, toMoneyCents, type Currency } from '../../lib/currency';
9
9
  import { CURRENCIES, DEFAULT_CURRENCY } from './CurrencySwitcher';
10
10
  import { ChangeBookingFlow } from './ChangeBookingFlow';
11
11
  import { VIAVIA_TICKET_FINE_PRINT_BY_CATEGORY } from './booking-flow-ui';
@@ -18,6 +18,7 @@ import { getItineraryStepLabel } from '../../lib/booking/itinerary-display';
18
18
  import { effectiveProductOptionIdForChangeFlow } from '../../lib/booking/product-option-id';
19
19
  import {
20
20
  BookingChangeSummaryColumn,
21
+ MobileBookingChangeDigest,
21
22
  changeBookingCompareColumnsVisuallyMatch,
22
23
  computeItineraryStepChanged,
23
24
  formatBookingDateForChangeCompare,
@@ -85,6 +86,7 @@ export default function ChangeBookingDialog({
85
86
  const [product, setProduct] = useState<Product | null>(null);
86
87
  const [error, setError] = useState<string | null>(null);
87
88
  const [newBookingPreview, setNewBookingPreview] = useState<ChangeFlowSelectionPreview | null>(null);
89
+ const [isMobileCompareExpanded, setIsMobileCompareExpanded] = useState(false);
88
90
  const dialogRef = useRef<HTMLDivElement>(null);
89
91
  const contentRef = useRef<HTMLDivElement>(null);
90
92
  const overlayRef = useRef<HTMLDivElement>(null);
@@ -189,7 +191,10 @@ export default function ChangeBookingDialog({
189
191
  }, [isOpen]);
190
192
 
191
193
  useEffect(() => {
192
- if (!isOpen) setNewBookingPreview(null);
194
+ if (!isOpen) {
195
+ setNewBookingPreview(null);
196
+ setIsMobileCompareExpanded(false);
197
+ }
193
198
  }, [isOpen]);
194
199
 
195
200
  const handleChangeFlowSelectionPreview = useCallback((preview: ChangeFlowSelectionPreview | null) => {
@@ -303,6 +308,35 @@ export default function ChangeBookingDialog({
303
308
  const itineraryStepChangedNew = showNewBookingColumn
304
309
  ? computeItineraryStepChanged(newItinerarySteps, currentItinerarySteps)
305
310
  : undefined;
311
+ const itineraryChangeCount = showNewBookingColumn
312
+ ? Math.max(
313
+ itineraryStepChangedCurrent?.filter(Boolean).length ?? 0,
314
+ itineraryStepChangedNew?.filter(Boolean).length ?? 0,
315
+ )
316
+ : 0;
317
+ const totalDeltaCents =
318
+ newBookingPreview?.selectionTotal == null
319
+ ? null
320
+ : toMoneyCents(newBookingPreview.selectionTotal) - toMoneyCents(booking.receipt.totalAmount);
321
+ const priceDeltaDirection =
322
+ totalDeltaCents == null
323
+ ? null
324
+ : totalDeltaCents > 0
325
+ ? 'increase'
326
+ : totalDeltaCents < 0
327
+ ? 'decrease'
328
+ : 'same';
329
+ const priceDeltaFormatted =
330
+ totalDeltaCents == null
331
+ ? null
332
+ : totalDeltaCents === 0
333
+ ? 'No price change'
334
+ : `${totalDeltaCents > 0 ? '+' : '−'}${formatCurrencyAmount(
335
+ Math.abs(totalDeltaCents) / 100,
336
+ receiptCurrency,
337
+ 'en',
338
+ )}`;
339
+ const mobileCompareDetailsId = 'mobile-booking-change-details';
306
340
 
307
341
  return (
308
342
  <div ref={overlayRef} className={`${styles.overlay} booking-dialog-overlay`} onClick={onClose}>
@@ -363,63 +397,90 @@ export default function ChangeBookingDialog({
363
397
  {product && (
364
398
  <div className={`${styles.screen} booking-flow-preflight`}>
365
399
  <div
366
- className="sticky top-0 z-10 mb-3 flex w-full flex-col gap-0 rounded-2xl py-2 md:flex-row md:items-stretch"
367
- style={{ background: 'var(--light-orange-background)' }}
400
+ className={styles.bookingCompareSticky}
368
401
  >
369
- <motion.div
370
- layout
371
- transition={{ type: 'spring', stiffness: 380, damping: 34 }}
372
- className={
373
- showNewBookingColumn
374
- ? 'min-w-0 md:flex-1 md:basis-0'
375
- : 'min-w-0 w-full'
376
- }
377
- >
378
- <BookingChangeSummaryColumn
379
- highlightVariant="current"
380
- kicker="Current booking"
381
- tourName={tourName}
382
- dateStr={currentDateStr}
383
- ticketsStr={ticketsLine}
384
- itinerarySteps={currentItinerarySteps}
385
- totalFormatted={currentTotalFormatted}
402
+ <div className={styles.mobileCompareDigestContainer}>
403
+ <MobileBookingChangeDigest
404
+ expanded={isMobileCompareExpanded}
405
+ detailsId={mobileCompareDetailsId}
406
+ showChanges={showNewBookingColumn}
407
+ currentDate={currentDateStr}
408
+ newDate={newDateStr}
409
+ currentTickets={ticketsLine}
410
+ newTickets={newTicketsStr}
411
+ currentTotal={currentTotalFormatted}
412
+ newTotal={newTotalFormatted}
386
413
  dateChanged={dateChanged}
387
414
  ticketsChanged={ticketsChanged}
388
- itineraryStepChanged={itineraryStepChangedCurrent}
415
+ itineraryChangeCount={itineraryChangeCount}
416
+ priceDeltaFormatted={priceDeltaFormatted}
417
+ priceDeltaDirection={priceDeltaDirection}
418
+ pricePending={showNewBookingColumn && newBookingPreview?.selectionTotal == null}
419
+ onToggle={() => setIsMobileCompareExpanded((expanded) => !expanded)}
389
420
  />
390
- </motion.div>
391
- {showNewBookingColumn ? (
392
- <div
393
- aria-hidden
394
- className="booking-change-compare-divider max-md:my-3 md:my-0"
395
- />
396
- ) : null}
397
- <AnimatePresence initial={false} mode="popLayout">
398
- {showNewBookingColumn && (
399
- <motion.div
400
- key="new-booking-summary"
401
- layout
402
- initial={{ opacity: 0, x: 16, scale: 0.99 }}
403
- animate={{ opacity: 1, x: 0, scale: 1 }}
404
- exit={{ opacity: 0, x: 16, scale: 0.99 }}
405
- transition={{ type: 'spring', stiffness: 380, damping: 34 }}
406
- className="min-w-0 md:flex-1 md:basis-0"
407
- >
408
- <BookingChangeSummaryColumn
409
- highlightVariant="new"
410
- kicker="New booking"
411
- tourName={tourName}
412
- dateStr={newDateStr}
413
- ticketsStr={newTicketsStr}
414
- itinerarySteps={newItinerarySteps}
415
- totalFormatted={newTotalFormatted}
416
- dateChanged={dateChanged}
417
- ticketsChanged={ticketsChanged}
418
- itineraryStepChanged={itineraryStepChangedNew}
419
- />
420
- </motion.div>
421
- )}
422
- </AnimatePresence>
421
+ </div>
422
+ <div
423
+ id={mobileCompareDetailsId}
424
+ className={`${styles.bookingCompareDetails} ${isMobileCompareExpanded ? 'flex' : 'hidden'} w-full flex-col gap-0 md:flex md:flex-row md:items-stretch`}
425
+ >
426
+ <motion.div
427
+ layout
428
+ transition={{ type: 'spring', stiffness: 380, damping: 34 }}
429
+ className={
430
+ showNewBookingColumn ? 'min-w-0 md:flex-1 md:basis-0' : 'min-w-0 w-full'
431
+ }
432
+ >
433
+ <BookingChangeSummaryColumn
434
+ highlightVariant="current"
435
+ kicker="Current booking"
436
+ tourName={tourName}
437
+ dateStr={currentDateStr}
438
+ ticketsStr={ticketsLine}
439
+ itinerarySteps={currentItinerarySteps}
440
+ totalFormatted={currentTotalFormatted}
441
+ dateChanged={dateChanged}
442
+ ticketsChanged={ticketsChanged}
443
+ itineraryStepChanged={itineraryStepChangedCurrent}
444
+ hideKickerOnMobile={!showNewBookingColumn}
445
+ />
446
+ </motion.div>
447
+ {showNewBookingColumn ? (
448
+ <div
449
+ aria-hidden
450
+ className="booking-change-compare-divider max-md:my-3 md:my-0"
451
+ />
452
+ ) : null}
453
+ <AnimatePresence initial={false} mode="popLayout">
454
+ {showNewBookingColumn && (
455
+ <motion.div
456
+ key="new-booking-summary"
457
+ layout
458
+ initial={{ opacity: 0, x: 16, scale: 0.99 }}
459
+ animate={{ opacity: 1, x: 0, scale: 1 }}
460
+ exit={{ opacity: 0, x: 16, scale: 0.99 }}
461
+ transition={{
462
+ type: 'spring',
463
+ stiffness: 380,
464
+ damping: 34,
465
+ }}
466
+ className="min-w-0 md:flex-1 md:basis-0"
467
+ >
468
+ <BookingChangeSummaryColumn
469
+ highlightVariant="new"
470
+ kicker="New booking"
471
+ tourName={tourName}
472
+ dateStr={newDateStr}
473
+ ticketsStr={newTicketsStr}
474
+ itinerarySteps={newItinerarySteps}
475
+ totalFormatted={newTotalFormatted}
476
+ dateChanged={dateChanged}
477
+ ticketsChanged={ticketsChanged}
478
+ itineraryStepChanged={itineraryStepChangedNew}
479
+ />
480
+ </motion.div>
481
+ )}
482
+ </AnimatePresence>
483
+ </div>
423
484
  </div>
424
485
  <ChangeBookingFlow
425
486
  product={product}
@@ -617,6 +617,7 @@ export function ChangeBookingFlow({
617
617
  locale={locale}
618
618
  t={t}
619
619
  taxRate={pricingConfig?.taxRate}
620
+ compactMobile
620
621
  />
621
622
  ) : null;
622
623