@ticketboothapp/booking 1.2.24 → 1.2.25-rc.0
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.
- package/package.json +29 -2
- package/src/assets/icons/minus.svg +7 -0
- package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
- package/src/assets/icons/plus.svg +3 -0
- package/src/colours.css +23 -0
- package/src/components/BookingDetails.module.css +1591 -0
- package/src/components/BookingDetails.tsx +2264 -0
- package/src/components/BookingWidget.tsx +302 -0
- package/src/components/ManageBookingView.tsx +437 -0
- package/src/components/PhoneInputWithCountry.module.css +131 -0
- package/src/components/PhoneInputWithCountry.tsx +44 -0
- package/src/components/PickupLocationDialog.module.css +360 -0
- package/src/components/PickupLocationDialog.tsx +357 -0
- package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
- package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
- package/src/components/booking/AddOnsSection.module.css +10 -0
- package/src/components/booking/AddOnsSection.tsx +184 -0
- package/src/components/booking/AdminPaymentChoiceModal.tsx +98 -0
- package/src/components/booking/BookingDialog.module.css +643 -0
- package/src/components/booking/BookingDialog.tsx +356 -0
- package/src/components/booking/BookingFlow.tsx +4385 -0
- package/src/components/booking/BookingFlowCollage.module.css +148 -0
- package/src/components/booking/BookingFlowCollage.tsx +184 -0
- package/src/components/booking/BookingFlowPlaceholder.module.css +27 -0
- package/src/components/booking/BookingFlowPlaceholder.tsx +25 -0
- package/src/components/booking/BookingFlowPreview.tsx +51 -0
- package/src/components/booking/BookingProductGrid.module.css +359 -0
- package/src/components/booking/BookingProductGrid.tsx +497 -0
- package/src/components/booking/Calendar.module.css +616 -0
- package/src/components/booking/Calendar.tsx +1123 -0
- package/src/components/booking/CancellationPolicySelector.module.css +124 -0
- package/src/components/booking/CancellationPolicySelector.tsx +142 -0
- package/src/components/booking/ChangeBookingDialog.tsx +562 -0
- package/src/components/booking/CheckoutForm.module.css +244 -0
- package/src/components/booking/CheckoutForm.tsx +364 -0
- package/src/components/booking/CheckoutModal.tsx +451 -0
- package/src/components/booking/CurrencySwitcher.tsx +81 -0
- package/src/components/booking/DapFlowCollage.tsx +88 -0
- package/src/components/booking/DapTourDescription.tsx +35 -0
- package/src/components/booking/DependentAddOnBookingDialog.tsx +1350 -0
- package/src/components/booking/DependentAddOnPaymentForm.tsx +124 -0
- package/src/components/booking/ErrorBoundary.tsx +63 -0
- package/src/components/booking/InfoTooltip.tsx +108 -0
- package/src/components/booking/ItineraryBox.module.css +258 -0
- package/src/components/booking/ItineraryBox.tsx +550 -0
- package/src/components/booking/ItineraryBuilder.tsx +82 -0
- package/src/components/booking/ItineraryPlaceholder.module.css +45 -0
- package/src/components/booking/ItineraryPlaceholder.tsx +26 -0
- package/src/components/booking/MealDrinkAddOnSelector.tsx +338 -0
- package/src/components/booking/PickupLocationSelector.module.css +124 -0
- package/src/components/booking/PickupLocationSelector.tsx +1566 -0
- package/src/components/booking/PickupTimeSelector.module.css +134 -0
- package/src/components/booking/PickupTimeSelector.tsx +112 -0
- package/src/components/booking/PriceBreakdown.tsx +154 -0
- package/src/components/booking/PriceSummary.tsx +234 -0
- package/src/components/booking/PrivateShuttleBookingFlow.module.css +357 -0
- package/src/components/booking/PrivateShuttleBookingFlow.tsx +2662 -0
- package/src/components/booking/PromoCodeInput.module.css +166 -0
- package/src/components/booking/PromoCodeInput.tsx +99 -0
- package/src/components/booking/ReturnTimeSelector.module.css +173 -0
- package/src/components/booking/ReturnTimeSelector.tsx +145 -0
- package/src/components/booking/TermsAcceptance.tsx +111 -0
- package/src/components/booking/TicketSelector.module.css +164 -0
- package/src/components/booking/TicketSelector.tsx +199 -0
- package/src/components/booking/TourDescription.module.css +304 -0
- package/src/components/booking/TourDescription.tsx +273 -0
- package/src/components/booking/booking-flow-ui.ts +38 -0
- package/src/components/booking/booking-flow.css +944 -0
- package/src/components/button.css +245 -0
- package/src/components/button.tsx +152 -0
- package/src/components/colorable-svg.tsx +29 -0
- package/src/components/image.css +29 -0
- package/src/components/image.tsx +113 -0
- package/src/components/partner/PartnerBookingPage.module.css +130 -0
- package/src/components/partner/PartnerBookingPage.tsx +390 -0
- package/src/components/partner/PartnerBookingPageWithBrowserMetadata.tsx +45 -0
- package/src/components/product-tag.module.css +30 -0
- package/src/components/product-tag.tsx +34 -0
- package/src/components/product-theme-pages/image-modal.tsx +248 -0
- package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
- package/src/components/terms/TermsContent.tsx +178 -0
- package/src/components/value-pill.module.css +59 -0
- package/src/components/value-pill.tsx +46 -0
- package/src/constants/images.ts +556 -0
- package/src/constants/pill-values.ts +210 -0
- package/src/constants/products.ts +155 -0
- package/src/contexts/AvailabilitiesCacheContext.tsx +125 -0
- package/src/contexts/BookingAppContext.tsx +134 -0
- package/src/contexts/CompanyContext.tsx +70 -0
- package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
- package/src/data/dap-descriptions/session-elopements.en.json +60 -0
- package/src/data/dap-descriptions/session-proposals.en.json +60 -0
- package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
- package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
- package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
- package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
- package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
- package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
- package/src/data/product-descriptions/private-tour.en.json +80 -0
- package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
- package/src/data/products-config.json +101 -0
- package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
- package/src/hooks/useIsBookingLaunchLive.ts +49 -0
- package/src/index.ts +79 -0
- package/src/lib/analytics.ts +197 -0
- package/src/lib/booking/booking-source.ts +51 -0
- package/src/lib/booking/checkout-breakdown.ts +69 -0
- package/src/lib/booking/correlation-id.ts +46 -0
- package/src/lib/booking/i18n/config.ts +21 -0
- package/src/lib/booking/i18n/index.tsx +144 -0
- package/src/lib/booking/i18n/messages/en.json +236 -0
- package/src/lib/booking/i18n/messages/fr.json +236 -0
- package/src/lib/booking/itinerary-display.ts +36 -0
- package/src/lib/booking/itinerary-labels.ts +70 -0
- package/src/lib/booking/location-calculations.ts +43 -0
- package/src/lib/booking/location-utils.ts +165 -0
- package/src/lib/booking/map-utils.ts +153 -0
- package/src/lib/booking/marker-icons.ts +113 -0
- package/src/lib/booking/normalize-booking-product-id.ts +21 -0
- package/src/lib/booking/pickup-location-types.ts +25 -0
- package/src/lib/booking/places-api.ts +154 -0
- package/src/lib/booking/pricing.ts +466 -0
- package/src/lib/booking/product-option-id.ts +35 -0
- package/src/lib/booking/source-metadata.ts +226 -0
- package/src/lib/booking/sunday-week.ts +14 -0
- package/src/lib/booking/theme.ts +83 -0
- package/src/lib/booking/trace-context.ts +62 -0
- package/src/lib/booking/utils.ts +9 -0
- package/src/lib/booking-api.ts +1793 -0
- package/src/lib/booking-constants.ts +23 -0
- package/src/lib/booking-ref.ts +13 -0
- package/src/lib/booking-types.ts +36 -0
- package/src/lib/currency.ts +81 -0
- package/src/lib/dap-descriptions.ts +50 -0
- package/src/lib/dap-itinerary-preview.ts +315 -0
- package/src/lib/dependent-add-on-api.ts +434 -0
- package/src/lib/env.ts +96 -0
- package/src/lib/firebase.ts +20 -0
- package/src/lib/job-application-api.ts +83 -0
- package/src/lib/manage-booking-embed-print.ts +16 -0
- package/src/lib/manage-booking-post-checkout.ts +68 -0
- package/src/lib/photo-dap-config.ts +228 -0
- package/src/lib/photo-packages.ts +75 -0
- package/src/lib/pickup/map-utils.ts +56 -0
- package/src/lib/pickup/marker-icons.ts +19 -0
- package/src/lib/product-descriptions.ts +66 -0
- package/src/lib/products-config.ts +73 -0
- package/src/providers/booking-dialog-provider.tsx +282 -0
- package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
- package/src/radius.css +5 -0
- package/src/spacing.css +7 -0
- package/src/strings/en.json +1774 -0
- package/src/strings/es.json +1573 -0
- package/src/strings/fr.json +1573 -0
- package/src/strings/index.js +23 -0
- package/src/text-style.css +56 -0
- package/src/utils/currency-converter.ts +101 -0
- package/tsconfig.json +8 -2
|
@@ -0,0 +1,497 @@
|
|
|
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
|
+
}
|