@ticketboothapp/booking 0.1.20 → 0.1.22

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 (53) hide show
  1. package/package.json +2 -1
  2. package/src/assets/icons/minus.svg +7 -0
  3. package/src/assets/icons/partner-logos/getyourguide.svg +8 -0
  4. package/src/assets/icons/plus.svg +3 -0
  5. package/src/colours.css +23 -0
  6. package/src/components/BookingDetails.module.css +1591 -0
  7. package/src/components/BookingDetails.tsx +2264 -0
  8. package/src/components/PhoneInputWithCountry.module.css +131 -0
  9. package/src/components/PhoneInputWithCountry.tsx +44 -0
  10. package/src/components/PickupLocationDialog.module.css +360 -0
  11. package/src/components/PickupLocationDialog.tsx +357 -0
  12. package/src/components/PostBookingDependentAddOnUpsell.module.css +174 -0
  13. package/src/components/PostBookingDependentAddOnUpsell.tsx +407 -0
  14. package/src/components/button.css +245 -0
  15. package/src/components/button.tsx +152 -0
  16. package/src/components/colorable-svg.tsx +29 -0
  17. package/src/components/image.css +29 -0
  18. package/src/components/image.tsx +113 -0
  19. package/src/components/product-tag.module.css +30 -0
  20. package/src/components/product-tag.tsx +34 -0
  21. package/src/components/product-theme-pages/image-modal.tsx +248 -0
  22. package/src/components/product-theme-pages/photo-gallery.module.css +200 -0
  23. package/src/components/terms/TermsContent.tsx +178 -0
  24. package/src/components/value-pill.module.css +59 -0
  25. package/src/components/value-pill.tsx +46 -0
  26. package/src/constants/images.ts +556 -0
  27. package/src/constants/pill-values.ts +210 -0
  28. package/src/constants/products.ts +155 -0
  29. package/src/data/dap-descriptions/session-couples-families-friends.en.json +61 -0
  30. package/src/data/dap-descriptions/session-elopements.en.json +60 -0
  31. package/src/data/dap-descriptions/session-proposals.en.json +60 -0
  32. package/src/data/product-descriptions/afternoon-delight.en.json +35 -0
  33. package/src/data/product-descriptions/emerald-lake-escape.en.json +68 -0
  34. package/src/data/product-descriptions/lake-louise-adventure.en.json +74 -0
  35. package/src/data/product-descriptions/moraine-lake-adventure.en.json +78 -0
  36. package/src/data/product-descriptions/moraine-lake-sunrise-lake-louise-golden-hour.en.json +65 -0
  37. package/src/data/product-descriptions/moraine-lake-sunrise.en.json +64 -0
  38. package/src/data/product-descriptions/private-tour.en.json +80 -0
  39. package/src/data/product-descriptions/two-lakes-combo.en.json +65 -0
  40. package/src/data/products-config.json +101 -0
  41. package/src/hooks/useBookingSourceMetadataFromLocation.ts +21 -0
  42. package/src/hooks/useIsBookingLaunchLive.ts +49 -0
  43. package/src/lib/photo-packages.ts +75 -0
  44. package/src/providers/dependent-add-on-dialog-provider.tsx +105 -0
  45. package/src/radius.css +5 -0
  46. package/src/spacing.css +7 -0
  47. package/src/strings/en.json +1774 -0
  48. package/src/strings/es.json +1573 -0
  49. package/src/strings/fr.json +1573 -0
  50. package/src/strings/index.js +23 -0
  51. package/src/text-style.css +97 -0
  52. package/src/utils/currency-converter.ts +101 -0
  53. package/tsconfig.json +1 -1
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import './button.css';
4
+ import Link from 'next/link';
5
+ import { OPEN_BOOKING_FOR_PRODUCT } from '@/providers/booking-dialog-provider';
6
+
7
+ enum ButtonHoverColor {
8
+ White = 'white',
9
+ Turquoise = 'turquoise',
10
+ Orange = 'orange'
11
+ }
12
+
13
+ enum PresetButtonActions {
14
+ BOOK_ALL = 'BOOK_ALL',
15
+ BOOK_MORAINE_LAKE_SUNRISE = 'BOOK_MORAINE_LAKE_SUNRISE',
16
+ BOOK_MORAINE_LAKE_SUNRISE_LAKE_LOUISE_GOLDEN_HOUR = 'BOOK_MORAINE_LAKE_SUNRISE_LAKE_LOUISE_GOLDEN_HOUR',
17
+ BOOK_TWO_LAKES_COMBO = 'BOOK_TWO_LAKES_COMBO',
18
+ BOOK_MORAINE_LAKE_ADVENTURE = 'BOOK_MORAINE_LAKE_ADVENTURE',
19
+ BOOK_LAKE_LOUISE_ADVENTURE = 'BOOK_LAKE_LOUISE_ADVENTURE',
20
+ BOOK_EMERALD_LAKE_ESCAPE = 'BOOK_EMERALD_LAKE_ESCAPE',
21
+ BOOK_PRIVATE_TOUR = 'BOOK_PRIVATE_TOUR',
22
+ BOOK_THEME_SUNRISE = 'BOOK_THEME_SUNRISE',
23
+ BOOK_THEME_MORAINE_LAKE = 'BOOK_THEME_MORAINE_LAKE',
24
+ BOOK_THEME_LAKE_LOUISE = 'BOOK_THEME_LAKE_LOUISE',
25
+ }
26
+
27
+ /** Maps preset booking actions to product slugs. null = open product grid. */
28
+ const PRESET_ACTION_TO_PRODUCT_SLUG: Partial<Record<PresetButtonActions, string | null>> = {
29
+ [PresetButtonActions.BOOK_ALL]: null,
30
+ [PresetButtonActions.BOOK_MORAINE_LAKE_SUNRISE]: 'moraine-lake-sunrise',
31
+ [PresetButtonActions.BOOK_MORAINE_LAKE_SUNRISE_LAKE_LOUISE_GOLDEN_HOUR]: 'moraine-lake-sunrise-lake-louise-golden-hour',
32
+ [PresetButtonActions.BOOK_TWO_LAKES_COMBO]: 'two-lakes-combo',
33
+ [PresetButtonActions.BOOK_MORAINE_LAKE_ADVENTURE]: 'moraine-lake-adventure',
34
+ [PresetButtonActions.BOOK_LAKE_LOUISE_ADVENTURE]: 'lake-louise-adventure',
35
+ [PresetButtonActions.BOOK_EMERALD_LAKE_ESCAPE]: 'emerald-lake-escape',
36
+ [PresetButtonActions.BOOK_PRIVATE_TOUR]: 'private-tour',
37
+ [PresetButtonActions.BOOK_THEME_SUNRISE]: 'moraine-lake-sunrise-lake-louise-golden-hour',
38
+ [PresetButtonActions.BOOK_THEME_MORAINE_LAKE]: 'moraine-lake-sunrise-lake-louise-golden-hour',
39
+ [PresetButtonActions.BOOK_THEME_LAKE_LOUISE]: 'lake-louise-adventure',
40
+ };
41
+
42
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
43
+ children: React.ReactNode;
44
+ variant?: 'primary' | 'secondary' | 'outline' | 'disabled';
45
+ hoverColor?: ButtonHoverColor;
46
+ isLarge?: boolean;
47
+ action?: ButtonAction;
48
+ shouldOpenInNewTab?: boolean;
49
+ }
50
+
51
+ type ButtonAction = PresetButtonActions | string;
52
+
53
+ export default function Button({
54
+ children,
55
+ variant = 'primary',
56
+ className,
57
+ hoverColor = ButtonHoverColor.White,
58
+ isLarge = false,
59
+ action = '',
60
+ shouldOpenInNewTab = false,
61
+ ...props
62
+ }: ButtonProps) {
63
+ const finalAction = getFinalAction(action);
64
+ const productSlug = getProductSlugForAction(action);
65
+ const isBookingAction = isModalActionType(action);
66
+
67
+ const handleClick = () => {
68
+ if (isBookingAction) {
69
+ if (productSlug) {
70
+ window.dispatchEvent(new CustomEvent(OPEN_BOOKING_FOR_PRODUCT, { detail: { productId: productSlug } }));
71
+ } else {
72
+ window.dispatchEvent(new CustomEvent('openSimpleModal'));
73
+ }
74
+ }
75
+ };
76
+
77
+ return (
78
+ <>
79
+ {isBookingAction ? (
80
+ <button
81
+ onClick={handleClick}
82
+ className={`button button-${variant} hover-${hoverColor} ${isLarge ? 'button-large' : ''} ${className}`}
83
+ {...props}
84
+ >
85
+ {children}
86
+ </button>
87
+ ) : finalAction ? (
88
+ <Link href={finalAction} target={shouldOpenInNewTab ? '_blank' : '_self'}>
89
+ <button
90
+ className={`button button-${variant} hover-${hoverColor} ${isLarge ? 'button-large' : ''} ${className}`}
91
+ {...props}
92
+ >
93
+ {children}
94
+ </button>
95
+ </Link>
96
+ ) : (
97
+ <button
98
+ className={`button button-${variant} hover-${hoverColor} ${isLarge ? 'button-large' : ''} ${className}`}
99
+ {...props}
100
+ >
101
+ {children}
102
+ </button>
103
+ )}
104
+ </>
105
+ )
106
+ }
107
+
108
+ function isModalActionType(action: ButtonAction): boolean {
109
+ return Object.values(PresetButtonActions).includes(action as PresetButtonActions);
110
+ }
111
+
112
+ function getProductSlugForAction(action: ButtonAction): string | null | undefined {
113
+ if (Object.values(PresetButtonActions).includes(action as PresetButtonActions)) {
114
+ return PRESET_ACTION_TO_PRODUCT_SLUG[action as PresetButtonActions] ?? null;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function getFinalAction(action: ButtonAction) {
120
+ // Preset booking actions open the dialog (handled by handleClick)
121
+ if (Object.values(PresetButtonActions).includes(action as PresetButtonActions)) {
122
+ return null;
123
+ }
124
+
125
+ // Handle regular URLs (http or relative paths)
126
+ if (typeof action === 'string' && (action.startsWith('http') || action.startsWith('/'))) {
127
+ return action;
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ export { ButtonHoverColor, PresetButtonActions };
134
+
135
+ interface MenuButtonProps {
136
+ onClick?: () => void;
137
+ isOpen?: boolean;
138
+ strings?: any;
139
+ }
140
+
141
+ export function MenuButton({ onClick, isOpen = false, strings }: MenuButtonProps) {
142
+ return (
143
+ <button className={`button button-menu ${isOpen ? 'open' : ''}`} onClick={onClick}>
144
+ <span className="menu-text">{strings.common.menu}</span>
145
+ <div className="menu-icon">
146
+ <span></span>
147
+ <span></span>
148
+ <span></span>
149
+ </div>
150
+ </button>
151
+ );
152
+ }
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+
3
+ interface ColorableSvgProps {
4
+ src: string;
5
+ className?: string;
6
+ width?: number;
7
+ height?: number;
8
+ }
9
+
10
+ export default function ColorableSvg({ src, className, width = 24, height = 24 }: ColorableSvgProps) {
11
+ const [svgContent, setSvgContent] = React.useState<string>('');
12
+
13
+ React.useEffect(() => {
14
+ fetch(src)
15
+ .then(res => res.text())
16
+ .then(text => {
17
+ // Replace both the style attribute and width/height attributes
18
+ const colorableSvg = text
19
+ .replace(/style="[^"]*"/, 'style="fill: currentColor"')
20
+ .replace(/width="[^"]*"/, `width="${width}"`)
21
+ .replace(/height="[^"]*"/, `height="${height}"`);
22
+ setSvgContent(colorableSvg);
23
+ });
24
+ }, [src, width, height]);
25
+
26
+ return (
27
+ <div className={className} dangerouslySetInnerHTML={{ __html: svgContent }} />
28
+ );
29
+ }
@@ -0,0 +1,29 @@
1
+ .image-container {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 100%;
5
+ }
6
+
7
+ .image-natural {
8
+ position: relative;
9
+ width: 100%;
10
+ height: auto;
11
+ }
12
+
13
+ /* Make sure images are responsive within their containers */
14
+ .image-container img {
15
+ width: 100%;
16
+ height: 100%;
17
+ object-fit: cover;
18
+ /* This centers the image content vertically when cropped */
19
+ object-position: 50% 50%;
20
+ }
21
+
22
+ .image-natural img {
23
+ display: block;
24
+ width: 100%;
25
+ height: auto;
26
+ max-width: 100%;
27
+ max-height: 100%;
28
+ object-fit: contain;
29
+ }
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import Image from 'next/image';
4
+ import './image.css';
5
+
6
+ interface ImageWidths {
7
+ mobile: number;
8
+ tablet: number;
9
+ desktop: number;
10
+ }
11
+
12
+ interface ImageWidthsMap {
13
+ HERO: ImageWidths;
14
+ GALLERY: ImageWidths;
15
+ THUMBNAIL: ImageWidths;
16
+ MODAL: ImageWidths;
17
+ }
18
+
19
+ const IMAGE_WIDTHS: ImageWidthsMap = {
20
+ HERO: {
21
+ mobile: 768,
22
+ tablet: 1024,
23
+ desktop: 1920
24
+ },
25
+ GALLERY: {
26
+ mobile: 400,
27
+ tablet: 600,
28
+ desktop: 800
29
+ },
30
+ THUMBNAIL: {
31
+ mobile: 200,
32
+ tablet: 300,
33
+ desktop: 400
34
+ },
35
+ MODAL: {
36
+ mobile: 1024,
37
+ tablet: 1280,
38
+ desktop: 1920
39
+ }
40
+ };
41
+
42
+ type ImageContext = 'HERO' | 'GALLERY' | 'THUMBNAIL' | 'MODAL';
43
+
44
+ interface ImageProps {
45
+ imageId: string;
46
+ context?: ImageContext;
47
+ alt: string;
48
+ className?: string;
49
+ maintainAspectRatio?: boolean;
50
+ style?: React.CSSProperties;
51
+ }
52
+
53
+ // Pre-compute sizes strings for each context to ensure consistency
54
+ const SIZES = {
55
+ HERO: '(min-width: 1024px) 1920px, (min-width: 768px) 1024px, 768px',
56
+ GALLERY: '(min-width: 1024px) 800px, (min-width: 768px) 600px, 400px',
57
+ THUMBNAIL: '(min-width: 1024px) 400px, (min-width: 768px) 300px, 200px',
58
+ MODAL: '(min-width: 1024px) 1920px, (min-width: 768px) 1280px, 1024px'
59
+ };
60
+
61
+ // Pre-compute quality values
62
+ const QUALITY = {
63
+ HERO: 85,
64
+ GALLERY: 75,
65
+ THUMBNAIL: 75,
66
+ MODAL: 85
67
+ };
68
+
69
+ const ViaViaImage = ({
70
+ className = '',
71
+ imageId,
72
+ context = 'THUMBNAIL',
73
+ alt,
74
+ maintainAspectRatio = false,
75
+ style
76
+ }: ImageProps) => {
77
+ const normalizedContext = context.toUpperCase() as ImageContext;
78
+ const widths = IMAGE_WIDTHS[normalizedContext];
79
+
80
+ if (maintainAspectRatio) {
81
+ return (
82
+ <div className={`image-container image-natural ${className}`}>
83
+ <Image
84
+ src={imageId}
85
+ alt={alt}
86
+ fill={false}
87
+ width={widths.desktop}
88
+ height={widths.desktop * 9 / 16}
89
+ sizes={SIZES[normalizedContext]}
90
+ priority={normalizedContext === 'HERO'}
91
+ quality={QUALITY[normalizedContext]}
92
+ style={{ width: '100%', height: 'auto', ...style }}
93
+ />
94
+ </div>
95
+ );
96
+ }
97
+
98
+ return (
99
+ <div className={`image-container image-${normalizedContext.toLowerCase()} ${className}`}>
100
+ <Image
101
+ src={imageId}
102
+ alt={alt}
103
+ fill={true}
104
+ sizes={SIZES[normalizedContext]}
105
+ priority={normalizedContext === 'HERO'}
106
+ quality={QUALITY[normalizedContext]}
107
+ style={style}
108
+ />
109
+ </div>
110
+ );
111
+ };
112
+
113
+ export default ViaViaImage;
@@ -0,0 +1,30 @@
1
+ .tag {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ padding: 0.4rem 0.8rem;
6
+ font-size: 0.75rem;
7
+ font-weight: 700;
8
+ text-transform: uppercase;
9
+ letter-spacing: 0.5px;
10
+ z-index: 4;
11
+ text-align: center;
12
+ line-height: 1.2;
13
+ border-radius: 4px;
14
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
15
+ margin-bottom: 8px;
16
+ white-space: nowrap;
17
+ overflow: hidden;
18
+ text-overflow: ellipsis;
19
+ }
20
+
21
+ /* Style variants */
22
+ .most_popular {
23
+ background-color: var(--accent-orange);
24
+ color: white;
25
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
26
+ }
27
+
28
+ .custom {
29
+ /* Custom styles will be applied via inline styles */
30
+ }
@@ -0,0 +1,34 @@
1
+ import styles from './product-tag.module.css';
2
+
3
+ export enum ProductTagStyle {
4
+ MOST_POPULAR = 'MOST_POPULAR',
5
+ CUSTOM = 'CUSTOM',
6
+ }
7
+
8
+ interface ProductTagProps {
9
+ text: string;
10
+ style: ProductTagStyle;
11
+ backgroundColor?: string;
12
+ textColor?: string;
13
+ }
14
+
15
+ export default function ProductTag({
16
+ text,
17
+ style,
18
+ backgroundColor,
19
+ textColor
20
+ }: ProductTagProps) {
21
+ const customStyles = style === ProductTagStyle.CUSTOM ? {
22
+ backgroundColor: backgroundColor || '#000000',
23
+ color: textColor || '#ffffff',
24
+ } : {};
25
+
26
+ return (
27
+ <div
28
+ className={`${styles.tag} ${styles[style.toLowerCase()]}`}
29
+ style={customStyles}
30
+ >
31
+ {text}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,248 @@
1
+ import { ImageData } from "@/constants/images";
2
+ import ViaViaImage from "@/components/image";
3
+ import styles from "./photo-gallery.module.css";
4
+ import { useEffect, useCallback, useRef, useState } from "react";
5
+
6
+ const SWIPE_THRESHOLD = 0.2; // 20% of width to trigger navigation
7
+ const DESKTOP_BREAKPOINT = 1024;
8
+
9
+ function useIsDesktop() {
10
+ const [isDesktop, setIsDesktop] = useState(true); // default to desktop (no swipe) to avoid flash
11
+ useEffect(() => {
12
+ const mq = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT}px)`);
13
+ setIsDesktop(mq.matches);
14
+ const handler = () => setIsDesktop(mq.matches);
15
+ mq.addEventListener("change", handler);
16
+ return () => mq.removeEventListener("change", handler);
17
+ }, []);
18
+ return isDesktop;
19
+ }
20
+
21
+ interface ImageModalProps {
22
+ selectedImage: ImageData;
23
+ currentIndex: number;
24
+ totalImages: number;
25
+ /** Full images array - required for swipe-to-slide effect */
26
+ images: ImageData[];
27
+ onClose: () => void;
28
+ onNext: () => void;
29
+ onPrevious: () => void;
30
+ /** Override z-index for overlay (e.g. when inside a dialog with high z-index) */
31
+ overlayZIndex?: number;
32
+ }
33
+
34
+ export default function ImageModal({
35
+ selectedImage,
36
+ currentIndex,
37
+ totalImages,
38
+ images,
39
+ onClose,
40
+ onNext,
41
+ onPrevious,
42
+ overlayZIndex = 1000,
43
+ }: ImageModalProps) {
44
+ const isDesktop = useIsDesktop();
45
+ const trackRef = useRef<HTMLDivElement>(null);
46
+ const containerRef = useRef<HTMLDivElement>(null);
47
+ const [dragOffset, setDragOffset] = useState(0);
48
+ const [containerWidth, setContainerWidth] = useState(400);
49
+ const [isDragging, setIsDragging] = useState(false);
50
+ const touchStartX = useRef(0);
51
+ const touchStartOffset = useRef(0);
52
+ const dragOffsetRef = useRef(0);
53
+ dragOffsetRef.current = dragOffset;
54
+
55
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
56
+ switch (e.key) {
57
+ case 'Escape':
58
+ e.stopImmediatePropagation();
59
+ onClose();
60
+ break;
61
+ case 'ArrowLeft':
62
+ onPrevious();
63
+ break;
64
+ case 'ArrowRight':
65
+ onNext();
66
+ break;
67
+ }
68
+ }, [onClose, onPrevious, onNext]);
69
+
70
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
71
+ touchStartX.current = e.touches[0].clientX;
72
+ touchStartOffset.current = dragOffset;
73
+ setIsDragging(true);
74
+ }, [dragOffset]);
75
+
76
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
77
+ if (!isDragging || !containerRef.current) return;
78
+ const delta = e.touches[0].clientX - touchStartX.current;
79
+ const containerWidth = containerRef.current.offsetWidth;
80
+ let newOffset = touchStartOffset.current + delta;
81
+ const maxDrag = containerWidth * 0.5;
82
+ if (currentIndex === 0 && newOffset > 0) newOffset = newOffset * 0.3;
83
+ else if (currentIndex === totalImages - 1 && newOffset < 0) newOffset = newOffset * 0.3;
84
+ else newOffset = Math.max(-maxDrag, Math.min(maxDrag, newOffset));
85
+ setDragOffset(newOffset);
86
+ }, [isDragging, currentIndex, totalImages]);
87
+
88
+ const handleTouchEnd = useCallback(() => {
89
+ if (!containerRef.current) return;
90
+ setIsDragging(false);
91
+ const width = containerRef.current.offsetWidth;
92
+ const offset = dragOffsetRef.current;
93
+ const threshold = width * SWIPE_THRESHOLD;
94
+ if (offset > threshold && currentIndex > 0) {
95
+ setDragOffset(0);
96
+ onPrevious();
97
+ } else if (offset < -threshold && currentIndex < totalImages - 1) {
98
+ setDragOffset(0);
99
+ onNext();
100
+ } else {
101
+ setDragOffset(0);
102
+ }
103
+ }, [currentIndex, totalImages, onNext, onPrevious]);
104
+
105
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
106
+ touchStartX.current = e.clientX;
107
+ touchStartOffset.current = dragOffset;
108
+ setIsDragging(true);
109
+ }, [dragOffset]);
110
+
111
+ useEffect(() => {
112
+ if (isDesktop) return;
113
+ const handleMouseMove = (e: MouseEvent) => {
114
+ if (!isDragging || !containerRef.current) return;
115
+ const delta = e.clientX - touchStartX.current;
116
+ const width = containerRef.current.offsetWidth;
117
+ let newOffset = touchStartOffset.current + delta;
118
+ const maxDrag = width * 0.5;
119
+ if (currentIndex === 0 && newOffset > 0) newOffset = newOffset * 0.3;
120
+ else if (currentIndex === totalImages - 1 && newOffset < 0) newOffset = newOffset * 0.3;
121
+ else newOffset = Math.max(-maxDrag, Math.min(maxDrag, newOffset));
122
+ setDragOffset(newOffset);
123
+ };
124
+ const handleMouseUp = () => {
125
+ if (!containerRef.current) return;
126
+ setIsDragging(false);
127
+ const width = containerRef.current.offsetWidth;
128
+ const offset = dragOffsetRef.current;
129
+ const threshold = width * SWIPE_THRESHOLD;
130
+ if (offset > threshold && currentIndex > 0) {
131
+ setDragOffset(0);
132
+ onPrevious();
133
+ } else if (offset < -threshold && currentIndex < totalImages - 1) {
134
+ setDragOffset(0);
135
+ onNext();
136
+ } else {
137
+ setDragOffset(0);
138
+ }
139
+ };
140
+ if (isDragging) {
141
+ window.addEventListener('mousemove', handleMouseMove);
142
+ window.addEventListener('mouseup', handleMouseUp);
143
+ }
144
+ return () => {
145
+ window.removeEventListener('mousemove', handleMouseMove);
146
+ window.removeEventListener('mouseup', handleMouseUp);
147
+ };
148
+ }, [isDesktop, isDragging, currentIndex, totalImages, onNext, onPrevious]);
149
+
150
+ useEffect(() => {
151
+ window.addEventListener('keydown', handleKeyDown, { capture: true });
152
+ return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
153
+ }, [handleKeyDown]);
154
+
155
+ useEffect(() => {
156
+ setDragOffset(0);
157
+ }, [currentIndex]);
158
+
159
+ useEffect(() => {
160
+ const el = containerRef.current;
161
+ if (!el) return;
162
+ const updateWidth = () => setContainerWidth(el.offsetWidth);
163
+ updateWidth();
164
+ const ro = new ResizeObserver(updateWidth);
165
+ ro.observe(el);
166
+ return () => ro.disconnect();
167
+ }, []);
168
+
169
+ const translateX = -currentIndex * containerWidth + dragOffset;
170
+
171
+ return (
172
+ <div
173
+ className={styles.modalOverlay}
174
+ onClick={onClose}
175
+ style={{ zIndex: overlayZIndex }}
176
+ >
177
+ <button className={styles.closeButton} onClick={onClose} aria-label="Close">
178
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
179
+ <path d="M18 6L6 18M6 6l12 12" />
180
+ </svg>
181
+ </button>
182
+ <button
183
+ className={`${styles.navButton} ${styles.prevButton}`}
184
+ onClick={(e) => {
185
+ e.stopPropagation();
186
+ onPrevious();
187
+ }}
188
+ disabled={currentIndex === 0}
189
+ aria-label="Previous image"
190
+ style={{ background: 'rgba(255,255,255,0.25)' }}
191
+ >
192
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
193
+ <path d="M15 18l-6-6 6-6" />
194
+ </svg>
195
+ </button>
196
+ <div
197
+ ref={containerRef}
198
+ className={`${styles.modalImageContainer} ${isDesktop ? styles.modalImageContainerDesktop : ''}`}
199
+ onTouchStart={handleTouchStart}
200
+ onTouchMove={handleTouchMove}
201
+ onTouchEnd={handleTouchEnd}
202
+ onTouchCancel={handleTouchEnd}
203
+ onMouseDown={isDesktop ? undefined : handleMouseDown}
204
+ onClick={(e) => e.stopPropagation()}
205
+ >
206
+ <div
207
+ ref={trackRef}
208
+ className={styles.modalImageTrack}
209
+ style={{
210
+ width: `${totalImages * 100}%`,
211
+ transform: `translateX(${translateX}px)`,
212
+ transition: isDragging ? 'none' : 'transform 0.3s ease-out',
213
+ }}
214
+ >
215
+ {images.map((img, i) => (
216
+ <div
217
+ key={img.id + i}
218
+ className={styles.modalImageSlide}
219
+ style={{ flex: `0 0 ${100 / totalImages}%` }}
220
+ >
221
+ <ViaViaImage
222
+ imageId={img.id}
223
+ alt={img.alt}
224
+ context="MODAL"
225
+ className={styles.modalImage}
226
+ maintainAspectRatio={true}
227
+ />
228
+ </div>
229
+ ))}
230
+ </div>
231
+ </div>
232
+ <button
233
+ className={`${styles.navButton} ${styles.nextButton}`}
234
+ onClick={(e) => {
235
+ e.stopPropagation();
236
+ onNext();
237
+ }}
238
+ disabled={currentIndex === totalImages - 1}
239
+ aria-label="Next image"
240
+ style={{ background: 'rgba(255,255,255,0.25)' }}
241
+ >
242
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
243
+ <path d="M9 18l6-6-6-6" />
244
+ </svg>
245
+ </button>
246
+ </div>
247
+ );
248
+ }