@ticketboothapp/booking 0.1.23 → 1.2.24

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 (158) hide show
  1. package/package.json +2 -29
  2. package/src/index.ts +0 -79
  3. package/tsconfig.json +2 -8
  4. package/src/assets/icons/minus.svg +0 -7
  5. package/src/assets/icons/partner-logos/getyourguide.svg +0 -8
  6. package/src/assets/icons/plus.svg +0 -3
  7. package/src/colours.css +0 -23
  8. package/src/components/BookingDetails.module.css +0 -1591
  9. package/src/components/BookingDetails.tsx +0 -2264
  10. package/src/components/BookingWidget.tsx +0 -302
  11. package/src/components/ManageBookingView.tsx +0 -437
  12. package/src/components/PhoneInputWithCountry.module.css +0 -131
  13. package/src/components/PhoneInputWithCountry.tsx +0 -44
  14. package/src/components/PickupLocationDialog.module.css +0 -360
  15. package/src/components/PickupLocationDialog.tsx +0 -357
  16. package/src/components/PostBookingDependentAddOnUpsell.module.css +0 -174
  17. package/src/components/PostBookingDependentAddOnUpsell.tsx +0 -407
  18. package/src/components/booking/AddOnsSection.module.css +0 -10
  19. package/src/components/booking/AddOnsSection.tsx +0 -184
  20. package/src/components/booking/AdminPaymentChoiceModal.tsx +0 -98
  21. package/src/components/booking/BookingDialog.module.css +0 -643
  22. package/src/components/booking/BookingDialog.tsx +0 -356
  23. package/src/components/booking/BookingFlow.tsx +0 -4385
  24. package/src/components/booking/BookingFlowCollage.module.css +0 -148
  25. package/src/components/booking/BookingFlowCollage.tsx +0 -184
  26. package/src/components/booking/BookingFlowPlaceholder.module.css +0 -27
  27. package/src/components/booking/BookingFlowPlaceholder.tsx +0 -25
  28. package/src/components/booking/BookingFlowPreview.tsx +0 -51
  29. package/src/components/booking/BookingProductGrid.module.css +0 -359
  30. package/src/components/booking/BookingProductGrid.tsx +0 -497
  31. package/src/components/booking/Calendar.module.css +0 -616
  32. package/src/components/booking/Calendar.tsx +0 -1123
  33. package/src/components/booking/CancellationPolicySelector.module.css +0 -124
  34. package/src/components/booking/CancellationPolicySelector.tsx +0 -142
  35. package/src/components/booking/ChangeBookingDialog.tsx +0 -562
  36. package/src/components/booking/CheckoutForm.module.css +0 -244
  37. package/src/components/booking/CheckoutForm.tsx +0 -364
  38. package/src/components/booking/CheckoutModal.tsx +0 -451
  39. package/src/components/booking/CurrencySwitcher.tsx +0 -81
  40. package/src/components/booking/DapFlowCollage.tsx +0 -88
  41. package/src/components/booking/DapTourDescription.tsx +0 -35
  42. package/src/components/booking/DependentAddOnBookingDialog.tsx +0 -1350
  43. package/src/components/booking/DependentAddOnPaymentForm.tsx +0 -124
  44. package/src/components/booking/ErrorBoundary.tsx +0 -63
  45. package/src/components/booking/InfoTooltip.tsx +0 -108
  46. package/src/components/booking/ItineraryBox.module.css +0 -258
  47. package/src/components/booking/ItineraryBox.tsx +0 -550
  48. package/src/components/booking/ItineraryBuilder.tsx +0 -82
  49. package/src/components/booking/ItineraryPlaceholder.module.css +0 -45
  50. package/src/components/booking/ItineraryPlaceholder.tsx +0 -26
  51. package/src/components/booking/MealDrinkAddOnSelector.tsx +0 -338
  52. package/src/components/booking/PickupLocationSelector.module.css +0 -124
  53. package/src/components/booking/PickupLocationSelector.tsx +0 -1566
  54. package/src/components/booking/PickupTimeSelector.module.css +0 -134
  55. package/src/components/booking/PickupTimeSelector.tsx +0 -112
  56. package/src/components/booking/PriceBreakdown.tsx +0 -154
  57. package/src/components/booking/PriceSummary.tsx +0 -234
  58. package/src/components/booking/PrivateShuttleBookingFlow.module.css +0 -357
  59. package/src/components/booking/PrivateShuttleBookingFlow.tsx +0 -2662
  60. package/src/components/booking/PromoCodeInput.module.css +0 -166
  61. package/src/components/booking/PromoCodeInput.tsx +0 -99
  62. package/src/components/booking/ReturnTimeSelector.module.css +0 -173
  63. package/src/components/booking/ReturnTimeSelector.tsx +0 -145
  64. package/src/components/booking/TermsAcceptance.tsx +0 -111
  65. package/src/components/booking/TicketSelector.module.css +0 -164
  66. package/src/components/booking/TicketSelector.tsx +0 -199
  67. package/src/components/booking/TourDescription.module.css +0 -304
  68. package/src/components/booking/TourDescription.tsx +0 -273
  69. package/src/components/booking/booking-flow-ui.ts +0 -38
  70. package/src/components/booking/booking-flow.css +0 -944
  71. package/src/components/button.css +0 -245
  72. package/src/components/button.tsx +0 -152
  73. package/src/components/colorable-svg.tsx +0 -29
  74. package/src/components/image.css +0 -29
  75. package/src/components/image.tsx +0 -113
  76. package/src/components/partner/PartnerBookingPage.module.css +0 -130
  77. package/src/components/partner/PartnerBookingPage.tsx +0 -390
  78. package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +0 -45
  79. package/src/components/product-tag.module.css +0 -30
  80. package/src/components/product-tag.tsx +0 -34
  81. package/src/components/product-theme-pages/image-modal.tsx +0 -248
  82. package/src/components/product-theme-pages/photo-gallery.module.css +0 -200
  83. package/src/components/terms/TermsContent.tsx +0 -178
  84. package/src/components/value-pill.module.css +0 -59
  85. package/src/components/value-pill.tsx +0 -46
  86. package/src/constants/images.ts +0 -556
  87. package/src/constants/pill-values.ts +0 -210
  88. package/src/constants/products.ts +0 -155
  89. package/src/contexts/AvailabilitiesCacheContext.tsx +0 -125
  90. package/src/contexts/BookingAppContext.tsx +0 -134
  91. package/src/contexts/CompanyContext.tsx +0 -70
  92. package/src/data/dap-descriptions/session-couples-families-friends.en.json +0 -61
  93. package/src/data/dap-descriptions/session-elopements.en.json +0 -60
  94. package/src/data/dap-descriptions/session-proposals.en.json +0 -60
  95. package/src/data/product-descriptions/afternoon-delight.en.json +0 -35
  96. package/src/data/product-descriptions/emerald-lake-escape.en.json +0 -68
  97. package/src/data/product-descriptions/lake-louise-adventure.en.json +0 -74
  98. package/src/data/product-descriptions/moraine-lake-adventure.en.json +0 -78
  99. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +0 -65
  100. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +0 -64
  101. package/src/data/product-descriptions/private-tour.en.json +0 -80
  102. package/src/data/product-descriptions/two-lakes-combo.en.json +0 -65
  103. package/src/data/products-config.json +0 -101
  104. package/src/hooks/useBookingSourceMetadataFromLocation.ts +0 -21
  105. package/src/hooks/useIsBookingLaunchLive.ts +0 -49
  106. package/src/lib/analytics.ts +0 -197
  107. package/src/lib/booking/booking-source.ts +0 -51
  108. package/src/lib/booking/checkout-breakdown.ts +0 -69
  109. package/src/lib/booking/correlation-id.ts +0 -46
  110. package/src/lib/booking/i18n/config.ts +0 -21
  111. package/src/lib/booking/i18n/index.tsx +0 -144
  112. package/src/lib/booking/i18n/messages/en.json +0 -236
  113. package/src/lib/booking/i18n/messages/fr.json +0 -236
  114. package/src/lib/booking/itinerary-display.ts +0 -36
  115. package/src/lib/booking/itinerary-labels.ts +0 -70
  116. package/src/lib/booking/location-calculations.ts +0 -43
  117. package/src/lib/booking/location-utils.ts +0 -165
  118. package/src/lib/booking/map-utils.ts +0 -153
  119. package/src/lib/booking/marker-icons.ts +0 -113
  120. package/src/lib/booking/normalize-booking-product-id.ts +0 -21
  121. package/src/lib/booking/pickup-location-types.ts +0 -25
  122. package/src/lib/booking/places-api.ts +0 -154
  123. package/src/lib/booking/pricing.ts +0 -466
  124. package/src/lib/booking/product-option-id.ts +0 -35
  125. package/src/lib/booking/source-metadata.ts +0 -226
  126. package/src/lib/booking/sunday-week.ts +0 -14
  127. package/src/lib/booking/theme.ts +0 -83
  128. package/src/lib/booking/trace-context.ts +0 -62
  129. package/src/lib/booking/utils.ts +0 -9
  130. package/src/lib/booking-api.ts +0 -1793
  131. package/src/lib/booking-constants.ts +0 -23
  132. package/src/lib/booking-ref.ts +0 -13
  133. package/src/lib/booking-types.ts +0 -36
  134. package/src/lib/currency.ts +0 -81
  135. package/src/lib/dap-descriptions.ts +0 -50
  136. package/src/lib/dap-itinerary-preview.ts +0 -315
  137. package/src/lib/dependent-add-on-api.ts +0 -434
  138. package/src/lib/env.ts +0 -96
  139. package/src/lib/firebase.ts +0 -20
  140. package/src/lib/job-application-api.ts +0 -83
  141. package/src/lib/manage-booking-embed-print.ts +0 -16
  142. package/src/lib/manage-booking-post-checkout.ts +0 -68
  143. package/src/lib/photo-dap-config.ts +0 -228
  144. package/src/lib/photo-packages.ts +0 -75
  145. package/src/lib/pickup/map-utils.ts +0 -56
  146. package/src/lib/pickup/marker-icons.ts +0 -19
  147. package/src/lib/product-descriptions.ts +0 -66
  148. package/src/lib/products-config.ts +0 -73
  149. package/src/providers/booking-dialog-provider.tsx +0 -282
  150. package/src/providers/dependent-add-on-dialog-provider.tsx +0 -105
  151. package/src/radius.css +0 -5
  152. package/src/spacing.css +0 -7
  153. package/src/strings/en.json +0 -1774
  154. package/src/strings/es.json +0 -1573
  155. package/src/strings/fr.json +0 -1573
  156. package/src/strings/index.js +0 -23
  157. package/src/text-style.css +0 -56
  158. package/src/utils/currency-converter.ts +0 -101
@@ -1,497 +0,0 @@
1
- 'use client';
2
-
3
- import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
4
- import { motion, AnimatePresence } from 'framer-motion';
5
- import {
6
- useBookingDialog,
7
- type ProductGridRestoreState,
8
- } from '@/providers/booking-dialog-provider';
9
- import { useLocale, useTranslations } from '@/lib/booking/i18n';
10
- import { getProducts } from '@/constants/products';
11
- import { getProductByIdOrSlug } from '@/lib/products-config';
12
- import { getProductDescription } from '@/lib/product-descriptions';
13
- import ViaViaImage from '@/components/image';
14
- import ValuePill from '@/components/value-pill';
15
- import ProductTag from '@/components/product-tag';
16
- import { PillVariant } from '@/components/value-pill';
17
- import type { Product } from '@/constants/products';
18
- import defaultStrings from '@/strings';
19
- import { getImageUrl } from '@/constants/images';
20
- import BackgroundPlayer from 'next-video/background-player';
21
- import styles from './BookingProductGrid.module.css';
22
- import Button, { ButtonHoverColor } from '@/components/button';
23
-
24
- const DEFAULT_VIDEO = {
25
- src: '/videos/via-via-moraine-lake-tour-video.mp4',
26
- webm: '/videos/via-via-moraine-lake-tour-video.webm',
27
- };
28
-
29
- // Filter definitions: product IDs that match each filter
30
- const FILTER_PRODUCT_IDS: Record<string, string[]> = {
31
- 'sunrise': ['moraine-lake-sunrise', 'moraine-lake-sunrise-lake-louise-golden-hour'],
32
- 'moraine-lake': [
33
- 'moraine-lake-sunrise',
34
- 'moraine-lake-sunrise-lake-louise-golden-hour',
35
- 'two-lakes-combo',
36
- 'moraine-lake-adventure',
37
- 'emerald-lake-escape',
38
- ],
39
- 'lake-louise': [
40
- 'lake-louise-adventure',
41
- 'two-lakes-combo',
42
- 'moraine-lake-sunrise-lake-louise-golden-hour',
43
- 'emerald-lake-escape',
44
- ],
45
- 'emerald-lake': ['emerald-lake-escape'],
46
- 'private': ['private-tour'],
47
- };
48
- export const FILTER_IDS = ['all', 'sunrise', 'moraine-lake', 'lake-louise', 'emerald-lake', 'private'] as const;
49
- export type FilterId = (typeof FILTER_IDS)[number];
50
-
51
- function getVideoForProduct(product: Product) {
52
- return product.videoUrl ?? DEFAULT_VIDEO;
53
- }
54
-
55
- function BookingProductTileCollapsed({
56
- product,
57
- onClick,
58
- useLayoutId,
59
- suppressLayoutAnimation,
60
- }: {
61
- product: Product;
62
- onClick: () => void;
63
- useLayoutId: boolean;
64
- suppressLayoutAnimation?: boolean;
65
- }) {
66
- return (
67
- <motion.div
68
- layout={!suppressLayoutAnimation}
69
- layoutId={suppressLayoutAnimation ? undefined : useLayoutId ? product.id : undefined}
70
- className={styles.tileCollapsed}
71
- onClick={onClick}
72
- transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
73
- >
74
- <div className={styles.tileImageContainer}>
75
- <ViaViaImage
76
- className={styles.tileImage}
77
- imageId={product.images[0].id}
78
- alt={product.images[0].alt}
79
- context="GALLERY"
80
- />
81
- <div className={styles.tileOverlay}>
82
- {product.tags && product.tags.length > 0 && (
83
- <div className={styles.tileTags}>
84
- {product.tags.map((tag, index) => (
85
- <ProductTag
86
- key={`${tag.text}-${index}`}
87
- text={tag.text}
88
- style={tag.style}
89
- />
90
- ))}
91
- </div>
92
- )}
93
- <h3 className={styles.tileTitle}>{product.shortName}</h3>
94
- </div>
95
- <span className={styles.tileStartTime } dangerouslySetInnerHTML={{ __html: product.currentStartTime }} />
96
- <div className={styles.tilePills}>
97
- {product.pillValues.map((pillValue, index) => (
98
- <ValuePill
99
- key={`${pillValue.label}-${index}`}
100
- variant={PillVariant.overlay}
101
- pillValue={pillValue}
102
- compact
103
- />
104
- ))}
105
- </div>
106
- </div>
107
- </motion.div>
108
- );
109
- }
110
-
111
- function BookingProductTileExpanded({
112
- product,
113
- onBook,
114
- onCollapse,
115
- onMount,
116
- useLayoutId,
117
- suppressLayoutAnimation,
118
- isPartialLaunch,
119
- }: {
120
- product: Product;
121
- onBook: () => void;
122
- onCollapse: () => void;
123
- onMount?: (el: HTMLDivElement) => void;
124
- useLayoutId: boolean;
125
- suppressLayoutAnimation?: boolean;
126
- isPartialLaunch: boolean;
127
- }) {
128
- const [isClosing, setIsClosing] = useState(false);
129
- const { locale } = useLocale();
130
- const productDesc = getProductDescription(product.id, locale);
131
- const displayDescription = productDesc?.shortDescription ?? product.description;
132
-
133
- const handleCollapseClick = () => {
134
- setIsClosing(true);
135
- setTimeout(() => onCollapse(), 300);
136
- };
137
-
138
- const isMobile = !useLayoutId;
139
-
140
- return (
141
- <motion.div
142
- ref={(el) => { if (el) onMount?.(el as HTMLDivElement); }}
143
- layout={!suppressLayoutAnimation}
144
- layoutId={suppressLayoutAnimation ? undefined : useLayoutId ? product.id : undefined}
145
- className={styles.tileExpanded}
146
- initial={suppressLayoutAnimation ? false : { opacity: 0 }}
147
- animate={{ opacity: 1 }}
148
- exit={
149
- isMobile
150
- ? {
151
- height: 0,
152
- opacity: 0,
153
- transition: { duration: 0.35, ease: [0.32, 0.72, 0, 1] },
154
- }
155
- : undefined
156
- }
157
- transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
158
- >
159
- <div className={styles.expandedInner}>
160
- <div className={styles.expandedImage}>
161
- <button
162
- type="button"
163
- className={styles.collapseArrowButton}
164
- onClick={handleCollapseClick}
165
- aria-label="Collapse"
166
- >
167
- <motion.svg
168
- width="20"
169
- height="20"
170
- viewBox="0 0 24 24"
171
- fill="none"
172
- stroke="currentColor"
173
- strokeWidth="2"
174
- strokeLinecap="round"
175
- strokeLinejoin="round"
176
- animate={{ rotate: isClosing ? 0 : 90 }}
177
- transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
178
- >
179
- <path d="M10 6l8 6-8 6" />
180
- </motion.svg>
181
- </button>
182
- <BackgroundPlayer
183
- {...getVideoForProduct(product)}
184
- autoPlay
185
- muted
186
- loop
187
- playsInline
188
- poster={getImageUrl(product.images[0].id)}
189
- className={styles.expandedVideo}
190
- />
191
- {product.tags && product.tags.length > 0 && (
192
- <div className={styles.expandedTags}>
193
- {product.tags.map((tag, index) => (
194
- <ProductTag
195
- key={`${tag.text}-${index}`}
196
- text={tag.text}
197
- style={tag.style}
198
- />
199
- ))}
200
- </div>
201
- )}
202
- </div>
203
- <div className={styles.expandedContent}>
204
- <h3 className={styles.expandedTitle}>{product.name}</h3>
205
- <p className={styles.expandedDescription} dangerouslySetInnerHTML={{ __html: displayDescription }} />
206
- <div className={styles.expandedPills}>
207
- {product.pillValues.map((pillValue, index) => (
208
- <ValuePill
209
- key={`${pillValue.label}-${index}`}
210
- variant={PillVariant.solid}
211
- pillValue={pillValue}
212
- compact
213
- />
214
- ))}
215
- </div>
216
- <div className={styles.expandedActions}>
217
- <Button className={styles.bookButton} hoverColor={ButtonHoverColor.Turquoise} onClick={onBook}>{isPartialLaunch ? defaultStrings.common.moreInfo : defaultStrings.common.bookNow}</Button>
218
- </div>
219
- </div>
220
- </div>
221
- </motion.div>
222
- );
223
- }
224
-
225
- interface BookingProductGridProps {
226
- contentRef?: React.RefObject<HTMLDivElement | null>;
227
- restoreState?: ProductGridRestoreState | null;
228
- onRestoreApplied?: () => void;
229
- /** Pre-select a filter when opening the grid (e.g. from a theme page). */
230
- initialFilterId?: FilterId;
231
- /** When true, shows "More Info" instead of "Book Now" (pre-launch state). */
232
- isPartialLaunch?: boolean;
233
- /**
234
- * Optional callback when user clicks "Book now" for a product.
235
- * When provided, BookingProductGrid will call this instead of opening the global BookingDialog.
236
- */
237
- onBookProduct?: (productId: string) => void;
238
- /**
239
- * When true with `onBookProduct`, a collapsed tile click books immediately (no expand).
240
- * Default false keeps expand-in-place behavior for the main booking dialog.
241
- */
242
- bookOnTileClick?: boolean;
243
- }
244
-
245
- function filterProductsByFilterId(products: Product[], filterId: FilterId): Product[] {
246
- if (filterId === 'all') return products;
247
- const ids = FILTER_PRODUCT_IDS[filterId];
248
- if (!ids) return products;
249
- return products.filter((p) => ids.includes(p.id));
250
- }
251
-
252
- export default function BookingProductGrid({
253
- contentRef,
254
- restoreState,
255
- onRestoreApplied,
256
- initialFilterId,
257
- isPartialLaunch = false,
258
- onBookProduct,
259
- bookOnTileClick = false,
260
- }: BookingProductGridProps = {}) {
261
- const { push } = useBookingDialog();
262
- const { t } = useTranslations();
263
- const hasAppliedRestore = useRef(false);
264
- const [expandedId, setExpandedId] = useState<string | null>(
265
- restoreState?.expandedId ?? null
266
- );
267
- const [selectedFilterId, setSelectedFilterId] = useState<FilterId>(
268
- initialFilterId && FILTER_IDS.includes(initialFilterId) ? initialFilterId : 'all'
269
- );
270
-
271
- const isRestoring = Boolean(restoreState);
272
-
273
- // Restore scroll position when returning from book flow (no animation)
274
- useEffect(() => {
275
- if (!isRestoring || !restoreState || !contentRef?.current) return;
276
- const scrollTop = restoreState.scrollTop;
277
- const el = contentRef.current;
278
- // Defer to next frame so grid layout is complete before restoring scroll
279
- const id = requestAnimationFrame(() => {
280
- if (el) {
281
- el.scrollTop = scrollTop;
282
- }
283
- if (!hasAppliedRestore.current) {
284
- hasAppliedRestore.current = true;
285
- onRestoreApplied?.();
286
- }
287
- });
288
- return () => cancelAnimationFrame(id);
289
- }, [isRestoring, restoreState, contentRef, onRestoreApplied]);
290
-
291
- const allProducts = useMemo(
292
- () => Object.values(getProducts(defaultStrings)),
293
- []
294
- );
295
-
296
- const products = useMemo(
297
- () => filterProductsByFilterId(allProducts, selectedFilterId),
298
- [allProducts, selectedFilterId]
299
- );
300
-
301
- // Collapse expanded tile if it's no longer in the filtered list
302
- useEffect(() => {
303
- if (expandedId && !products.some((p) => p.id === expandedId)) {
304
- setExpandedId(null);
305
- }
306
- }, [products, expandedId]);
307
-
308
- const expandedIndex = expandedId
309
- ? products.findIndex((p) => p.id === expandedId)
310
- : -1;
311
- const expandedProduct =
312
- expandedIndex >= 0 ? products[expandedIndex] : null;
313
-
314
- // Column count for expand-in-place logic (2 mobile, 3 desktop)
315
- const [cols, setCols] = useState(3);
316
- useEffect(() => {
317
- const mq = window.matchMedia('(min-width: 768px)');
318
- const update = () => setCols(mq.matches ? 3 : 2);
319
- update();
320
- mq.addEventListener('change', update);
321
- return () => mq.removeEventListener('change', update);
322
- }, []);
323
-
324
- // Expand in place: keep expanded tile in its row, move same-row siblings below.
325
- // This avoids holes and prevents the expanded tile from appearing far from the click.
326
- const { productsAbove, productsSameRow, productsBelow } = useMemo(() => {
327
- if (expandedIndex < 0) {
328
- return {
329
- productsAbove: products,
330
- productsSameRow: [] as Product[],
331
- productsBelow: [] as Product[],
332
- };
333
- }
334
- const expandedRow = Math.floor(expandedIndex / cols);
335
- const rowStart = expandedRow * cols;
336
- const rowEnd = rowStart + cols;
337
- const above = products.slice(0, rowStart);
338
- const sameRow = products
339
- .slice(rowStart, rowEnd)
340
- .filter((_, i) => rowStart + i !== expandedIndex);
341
- const below = products.slice(rowEnd);
342
- return {
343
- productsAbove: above,
344
- productsSameRow: sameRow,
345
- productsBelow: below,
346
- };
347
- }, [products, expandedIndex, cols]);
348
-
349
- const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
350
-
351
- const scrollExpandedToTop = useCallback(
352
- (el: HTMLDivElement) => {
353
- if (isRestoring) return; // Skip scroll animation when restoring
354
- if (scrollTimeoutRef.current) {
355
- clearTimeout(scrollTimeoutRef.current);
356
- scrollTimeoutRef.current = null;
357
- }
358
- const findScrollParent = (node: HTMLElement): HTMLElement | null => {
359
- let parent = node.parentElement;
360
- while (parent) {
361
- const { overflowY } = getComputedStyle(parent);
362
- if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
363
- return parent;
364
- }
365
- parent = parent.parentElement;
366
- }
367
- return null;
368
- };
369
-
370
- const isLastRow = productsBelow.length === 0;
371
-
372
- const run = () => {
373
- scrollTimeoutRef.current = null;
374
- const scrollParent = findScrollParent(el);
375
- const elRect = el.getBoundingClientRect();
376
-
377
- if (scrollParent) {
378
- const containerRect = scrollParent.getBoundingClientRect();
379
- let scrollDelta = elRect.top - containerRect.top;
380
- // Last row: reduce scroll slightly to avoid overshooting and extra white space below
381
- if (isLastRow) scrollDelta -= 24;
382
- scrollParent.scrollTo({
383
- top: scrollParent.scrollTop + scrollDelta,
384
- behavior: 'smooth',
385
- });
386
- } else if (typeof window !== 'undefined') {
387
- const offsetTop = window.scrollY + elRect.top - 120; // leave space for navbar
388
- window.scrollTo({ top: offsetTop, behavior: 'smooth' });
389
- }
390
- };
391
-
392
- // Wait for layout animation to fully complete (350ms + buffer) before scrolling.
393
- // Scrolling earlier causes wrong position when switching between expanded items.
394
- scrollTimeoutRef.current = setTimeout(run, 550);
395
- },
396
- [isRestoring, productsBelow.length]
397
- );
398
-
399
- const handleBook = (product: Product) => {
400
- const config = getProductByIdOrSlug(product.id);
401
- const productSlugOrId = config?.display.slug ?? product.id;
402
-
403
- if (onBookProduct) {
404
- onBookProduct(productSlugOrId);
405
- return;
406
- }
407
-
408
- const scrollTop = contentRef?.current?.scrollTop ?? 0;
409
- push(
410
- {
411
- type: 'book-flow',
412
- productId: productSlugOrId,
413
- },
414
- {
415
- productGridState: {
416
- expandedId,
417
- scrollTop,
418
- },
419
- }
420
- );
421
- };
422
-
423
- const onCollapsedTileClick = (product: Product) => {
424
- if (bookOnTileClick && onBookProduct) {
425
- handleBook(product);
426
- return;
427
- }
428
- setExpandedId((prev) => (prev === product.id ? null : product.id));
429
- };
430
-
431
- const filterLabels: Record<FilterId, string> = {
432
- all: t('products.productGridFilters.all'),
433
- 'sunrise': t('products.productGridFilters.sunrise'),
434
- 'moraine-lake': t('products.productGridFilters.moraineLake'),
435
- 'lake-louise': t('products.productGridFilters.lakeLouise'),
436
- 'emerald-lake': t('products.productGridFilters.emeraldLake'),
437
- 'private': t('products.productGridFilters.private'),
438
- };
439
-
440
- return (
441
- <div>
442
- <div className={styles.filterPillsScroll}>
443
- <div className={styles.filterPills}>
444
- {FILTER_IDS.map((filterId) => {
445
- const isApplied = selectedFilterId === filterId;
446
- return (
447
- <button
448
- key={filterId}
449
- type="button"
450
- onClick={() => setSelectedFilterId(filterId)}
451
- className={`${styles.filterPill} ${isApplied ? styles.filterPillApplied : ''}`}
452
- >
453
- {filterLabels[filterId]}
454
- </button>
455
- );
456
- })}
457
- </div>
458
- </div>
459
- <div className={styles.grid}>
460
- <AnimatePresence mode={isRestoring ? 'sync' : 'popLayout'}>
461
- {productsAbove.map((product) => (
462
- <BookingProductTileCollapsed
463
- key={product.id}
464
- product={product}
465
- onClick={() => onCollapsedTileClick(product)}
466
- useLayoutId={cols === 3}
467
- suppressLayoutAnimation={isRestoring}
468
- />
469
- ))}
470
-
471
- {expandedProduct && (
472
- <BookingProductTileExpanded
473
- key={`expanded-${expandedProduct.id}`}
474
- product={expandedProduct}
475
- onBook={() => handleBook(expandedProduct)}
476
- onCollapse={() => setExpandedId(null)}
477
- onMount={scrollExpandedToTop}
478
- useLayoutId={cols === 3}
479
- suppressLayoutAnimation={isRestoring}
480
- isPartialLaunch={isPartialLaunch}
481
- />
482
- )}
483
-
484
- {[...productsSameRow, ...productsBelow].map((product) => (
485
- <BookingProductTileCollapsed
486
- key={product.id}
487
- product={product}
488
- onClick={() => onCollapsedTileClick(product)}
489
- useLayoutId={cols === 3}
490
- suppressLayoutAnimation={isRestoring}
491
- />
492
- ))}
493
- </AnimatePresence>
494
- </div>
495
- </div>
496
- );
497
- }