@snowcone-app/ui 0.1.43 → 0.2.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/CHANGELOG.md +26 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MobileProductCarousel - Production-grade carousel for product images and mockups
|
|
5
|
+
*
|
|
6
|
+
* A full-featured carousel optimized for e-commerce product display with:
|
|
7
|
+
* - Touch and mouse gesture support with drag feedback
|
|
8
|
+
* - Memory optimization (decode window) for iOS Safari
|
|
9
|
+
* - Real-time mockup URL integration via RealtimeProvider
|
|
10
|
+
* - Priority-based rendering via MockupPriorityProvider
|
|
11
|
+
* - Peek animation for UX education (teaches users they can swipe)
|
|
12
|
+
* - EnhancedImageViewer integration for pinch-to-zoom
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* // Basic usage within provider stack
|
|
17
|
+
* <ShopProvider>
|
|
18
|
+
* <RealtimeProvider>
|
|
19
|
+
* <MobileProductCarousel
|
|
20
|
+
* images={heroImages}
|
|
21
|
+
* productId="tshirt-001"
|
|
22
|
+
* currentArtwork={artwork}
|
|
23
|
+
* />
|
|
24
|
+
* </RealtimeProvider>
|
|
25
|
+
* </ShopProvider>
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* // With controlled navigation
|
|
31
|
+
* const [index, setIndex] = useState(0);
|
|
32
|
+
*
|
|
33
|
+
* <MobileProductCarousel
|
|
34
|
+
* images={images}
|
|
35
|
+
* currentIndex={index}
|
|
36
|
+
* onIndexChange={setIndex}
|
|
37
|
+
* enablePeekAnimation={true}
|
|
38
|
+
* enableZoom={true}
|
|
39
|
+
* />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import React, {
|
|
44
|
+
useState,
|
|
45
|
+
useRef,
|
|
46
|
+
useEffect,
|
|
47
|
+
useCallback,
|
|
48
|
+
useMemo,
|
|
49
|
+
memo,
|
|
50
|
+
} from "react";
|
|
51
|
+
import type { CarouselImage } from "./types";
|
|
52
|
+
import { EnhancedImageViewer } from "../zoom/EnhancedImageViewer";
|
|
53
|
+
import { HeroProductImage } from "../HeroProductImage";
|
|
54
|
+
import type { ArtworkData } from "@snowcone-app/sdk";
|
|
55
|
+
import { useMockupPriorityOptional } from "../../patterns/MockupPriorityProvider";
|
|
56
|
+
import { useRealtimeOptional } from "../../patterns/RealtimeProvider";
|
|
57
|
+
|
|
58
|
+
export interface MobileProductCarouselProps {
|
|
59
|
+
/** Array of images to display in the carousel */
|
|
60
|
+
images: CarouselImage[];
|
|
61
|
+
|
|
62
|
+
/** Current active slide index (controlled) */
|
|
63
|
+
currentIndex?: number;
|
|
64
|
+
|
|
65
|
+
/** Callback when the active slide changes */
|
|
66
|
+
onIndexChange?: (index: number) => void;
|
|
67
|
+
|
|
68
|
+
/** Additional CSS classes */
|
|
69
|
+
className?: string;
|
|
70
|
+
|
|
71
|
+
/** Current artwork for mockup generation */
|
|
72
|
+
currentArtwork?: ArtworkData;
|
|
73
|
+
|
|
74
|
+
/** Product ID for mockup generation */
|
|
75
|
+
productId?: string;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Show peek animation after N page views without swiping.
|
|
79
|
+
* Teaches users they can swipe through images.
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
enablePeekAnimation?: boolean;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enable tap-to-zoom with EnhancedImageViewer.
|
|
86
|
+
* @default true
|
|
87
|
+
*/
|
|
88
|
+
enableZoom?: boolean;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Show progress indicators below the carousel.
|
|
92
|
+
* @default true
|
|
93
|
+
*/
|
|
94
|
+
showIndicators?: boolean;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Callback when the zoom viewer opens or closes.
|
|
98
|
+
*/
|
|
99
|
+
onZoomChange?: (isZoomed: boolean) => void;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Callback for sticky hero context integration.
|
|
103
|
+
* When provided, the carousel reports its state to the parent layout.
|
|
104
|
+
*/
|
|
105
|
+
stickyHeroContext?: {
|
|
106
|
+
isStuck?: boolean;
|
|
107
|
+
shouldHideDots?: boolean;
|
|
108
|
+
setCarouselIndicator?: (indicator: React.ReactNode) => void;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Explicit mockup width in device pixels to pass to HeroProductImage.
|
|
113
|
+
* Use when the container uses object-fit: cover with a taller-than-natural
|
|
114
|
+
* aspect ratio (e.g. ScrollHero) so the mockup is requested at the
|
|
115
|
+
* cover-adjusted resolution instead of just the container CSS width.
|
|
116
|
+
*/
|
|
117
|
+
mockupWidth?: number;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Fires when `HeroProductImage` finishes generating a mockup URL for a
|
|
121
|
+
* given mockupId — covers the static (SDK-built) URL on first load AND
|
|
122
|
+
* any realtime URL that subsequently replaces it. Lets the parent share
|
|
123
|
+
* the URL with sibling surfaces (e.g. the mobile editor preview) that
|
|
124
|
+
* would otherwise have to wait for a realtime event to populate the
|
|
125
|
+
* provider's mockup cache themselves.
|
|
126
|
+
*/
|
|
127
|
+
onMockupUrlGenerated?: (mockupId: string, url: string) => void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* MobileProductCarousel - Memoized for performance
|
|
132
|
+
*
|
|
133
|
+
* Prevents re-renders from parent state changes when props are stable.
|
|
134
|
+
* Critical for preventing HeroProductImage children from re-rendering unnecessarily.
|
|
135
|
+
*/
|
|
136
|
+
export const MobileProductCarousel = memo(function MobileProductCarousel({
|
|
137
|
+
images,
|
|
138
|
+
currentIndex = 0,
|
|
139
|
+
onIndexChange,
|
|
140
|
+
className = "",
|
|
141
|
+
currentArtwork,
|
|
142
|
+
productId,
|
|
143
|
+
enablePeekAnimation = true,
|
|
144
|
+
enableZoom = true,
|
|
145
|
+
showIndicators = true,
|
|
146
|
+
onZoomChange,
|
|
147
|
+
stickyHeroContext,
|
|
148
|
+
mockupWidth,
|
|
149
|
+
onMockupUrlGenerated,
|
|
150
|
+
}: MobileProductCarouselProps) {
|
|
151
|
+
const [activeIndex, setActiveIndex] = useState(currentIndex);
|
|
152
|
+
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(
|
|
153
|
+
null
|
|
154
|
+
);
|
|
155
|
+
const [touchEnd, setTouchEnd] = useState<{ x: number; y: number } | null>(
|
|
156
|
+
null
|
|
157
|
+
);
|
|
158
|
+
const [showImageViewer, setShowImageViewer] = useState(false);
|
|
159
|
+
const [viewerKey, setViewerKey] = useState(0);
|
|
160
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
161
|
+
const [dragOffset, setDragOffset] = useState(0);
|
|
162
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
163
|
+
const [isHorizontalSwipe, setIsHorizontalSwipe] = useState<boolean | null>(
|
|
164
|
+
null
|
|
165
|
+
);
|
|
166
|
+
const [isPeeking, setIsPeeking] = useState(false);
|
|
167
|
+
const [isPeekReturning, setIsPeekReturning] = useState(false);
|
|
168
|
+
const carouselRef = useRef<HTMLDivElement>(null);
|
|
169
|
+
const indicatorRef = useRef<HTMLDivElement>(null);
|
|
170
|
+
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(
|
|
171
|
+
null
|
|
172
|
+
);
|
|
173
|
+
const horizontalGestureRef = useRef(false);
|
|
174
|
+
const hasShownPeekRef = useRef(false);
|
|
175
|
+
const hasSwipedThisSessionRef = useRef(false);
|
|
176
|
+
|
|
177
|
+
// Cache for generated mockup URLs - persists across unmount/remount cycles
|
|
178
|
+
// This is critical for the decode window optimization to work correctly
|
|
179
|
+
const generatedUrlsRef = useRef<Map<string, string>>(new Map());
|
|
180
|
+
|
|
181
|
+
// Track previous artwork to detect changes and clear stale cache
|
|
182
|
+
const prevArtworkSrcRef = useRef<string | undefined>(currentArtwork?.src);
|
|
183
|
+
|
|
184
|
+
// Clear generated URLs cache when artwork changes
|
|
185
|
+
// These cached URLs were for the old artwork and are now stale
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (currentArtwork?.src !== prevArtworkSrcRef.current) {
|
|
188
|
+
generatedUrlsRef.current.clear();
|
|
189
|
+
prevArtworkSrcRef.current = currentArtwork?.src;
|
|
190
|
+
}
|
|
191
|
+
}, [currentArtwork?.src]);
|
|
192
|
+
|
|
193
|
+
// Stable ref so updates to the prop don't invalidate handleUrlGenerated
|
|
194
|
+
// (which would recreate HeroProductImage's onUrlGenerated each render and
|
|
195
|
+
// trip its lastUrlRef-based dedup, retriggering generation churn).
|
|
196
|
+
const onMockupUrlGeneratedRef = useRef(onMockupUrlGenerated);
|
|
197
|
+
onMockupUrlGeneratedRef.current = onMockupUrlGenerated;
|
|
198
|
+
|
|
199
|
+
// Callback to collect URLs generated by HeroProductImage
|
|
200
|
+
const handleUrlGenerated = useCallback((mockupId: string, url: string) => {
|
|
201
|
+
generatedUrlsRef.current.set(mockupId, url);
|
|
202
|
+
onMockupUrlGeneratedRef.current?.(mockupId, url);
|
|
203
|
+
}, []);
|
|
204
|
+
|
|
205
|
+
// Context integration (optional - works with or without providers)
|
|
206
|
+
const isStuck = stickyHeroContext?.isStuck ?? false;
|
|
207
|
+
const shouldHideDots = stickyHeroContext?.shouldHideDots ?? false;
|
|
208
|
+
|
|
209
|
+
// Priority context for mobile carousel mode
|
|
210
|
+
const priorityContext = useMockupPriorityOptional();
|
|
211
|
+
|
|
212
|
+
// Realtime context for getting updated mockup URLs from centralized cache
|
|
213
|
+
const realtimeContext = useRealtimeOptional();
|
|
214
|
+
|
|
215
|
+
// Subscribe to mockup result updates to trigger re-renders when URLs change
|
|
216
|
+
const [urlUpdateCounter, setUrlUpdateCounter] = useState(0);
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!realtimeContext?.subscribeMockupResults) return;
|
|
219
|
+
|
|
220
|
+
const unsubscribe = realtimeContext.subscribeMockupResults(() => {
|
|
221
|
+
setUrlUpdateCounter((c) => c + 1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return unsubscribe;
|
|
225
|
+
}, [realtimeContext?.subscribeMockupResults]);
|
|
226
|
+
|
|
227
|
+
// Get all mockup IDs for this carousel
|
|
228
|
+
const mockupIds = useMemo(() => {
|
|
229
|
+
return images
|
|
230
|
+
.filter((img) => img.isRealMockup && img.mockupId)
|
|
231
|
+
.map((img) => img.mockupId!);
|
|
232
|
+
}, [images]);
|
|
233
|
+
|
|
234
|
+
// Get realtime URLs from centralized cache
|
|
235
|
+
const realtimeMockupUrls = useMemo(() => {
|
|
236
|
+
if (!realtimeContext?.getMockupUrls || mockupIds.length === 0) {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
return realtimeContext.getMockupUrls(mockupIds);
|
|
240
|
+
}, [realtimeContext?.getMockupUrls, mockupIds, urlUpdateCounter]);
|
|
241
|
+
|
|
242
|
+
// Create enhanced images array with realtime URLs substituted for mockups
|
|
243
|
+
const imagesWithRealtimeUrls = useMemo(() => {
|
|
244
|
+
return images.map((img) => {
|
|
245
|
+
if (img.isRealMockup && img.mockupId && realtimeMockupUrls[img.mockupId]) {
|
|
246
|
+
return {
|
|
247
|
+
...img,
|
|
248
|
+
src: realtimeMockupUrls[img.mockupId],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return img;
|
|
252
|
+
});
|
|
253
|
+
}, [images, realtimeMockupUrls]);
|
|
254
|
+
|
|
255
|
+
// Minimum distance required to register a swipe
|
|
256
|
+
const minSwipeDistance = 50;
|
|
257
|
+
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
setActiveIndex(currentIndex);
|
|
260
|
+
}, [currentIndex]);
|
|
261
|
+
|
|
262
|
+
// Report carousel index to priority context for mobile-optimized priority
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!priorityContext) return;
|
|
265
|
+
|
|
266
|
+
const realMockups = images
|
|
267
|
+
.map((img, idx) => ({ img, originalIdx: idx }))
|
|
268
|
+
.filter(({ img }) => img.isRealMockup && img.mockupId);
|
|
269
|
+
|
|
270
|
+
const mockupIdsList = realMockups.map(({ img }) => img.mockupId!);
|
|
271
|
+
|
|
272
|
+
if (mockupIdsList.length > 0) {
|
|
273
|
+
const currentImage = images[activeIndex];
|
|
274
|
+
let mockupIndex = 0;
|
|
275
|
+
|
|
276
|
+
if (currentImage?.isRealMockup && currentImage?.mockupId) {
|
|
277
|
+
mockupIndex = realMockups.findIndex(
|
|
278
|
+
({ originalIdx }) => originalIdx === activeIndex
|
|
279
|
+
);
|
|
280
|
+
if (mockupIndex === -1) mockupIndex = 0;
|
|
281
|
+
} else {
|
|
282
|
+
let minDistance = Infinity;
|
|
283
|
+
realMockups.forEach(({ originalIdx }, idx) => {
|
|
284
|
+
const distance = Math.abs(originalIdx - activeIndex);
|
|
285
|
+
if (distance < minDistance) {
|
|
286
|
+
minDistance = distance;
|
|
287
|
+
mockupIndex = idx;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
priorityContext.reportMobileCarouselIndex(mockupIndex, mockupIdsList);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return () => {
|
|
296
|
+
priorityContext.clearMobileCarouselMode?.();
|
|
297
|
+
};
|
|
298
|
+
}, [priorityContext, activeIndex, images]);
|
|
299
|
+
|
|
300
|
+
// Counter-scale indicator width for HeroZoomLayout compatibility
|
|
301
|
+
useEffect(() => {
|
|
302
|
+
const indicator = indicatorRef.current;
|
|
303
|
+
if (!indicator) return;
|
|
304
|
+
|
|
305
|
+
let ticking = false;
|
|
306
|
+
|
|
307
|
+
const updateIndicatorWidth = () => {
|
|
308
|
+
const heroContainer = document.querySelector(
|
|
309
|
+
"[data-hero-zoom-container]"
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
if (!heroContainer) {
|
|
313
|
+
indicator.style.width = "";
|
|
314
|
+
indicator.style.left = "";
|
|
315
|
+
indicator.style.transform = "";
|
|
316
|
+
indicator.style.transformOrigin = "";
|
|
317
|
+
ticking = false;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const scaleStr = getComputedStyle(heroContainer).scale;
|
|
322
|
+
const currentScale = parseFloat(scaleStr) || 1;
|
|
323
|
+
const counterScale = 1 / currentScale;
|
|
324
|
+
|
|
325
|
+
indicator.style.width = "100vw";
|
|
326
|
+
indicator.style.left = "50%";
|
|
327
|
+
indicator.style.transformOrigin = "center bottom";
|
|
328
|
+
indicator.style.transform = `translateX(-50%) scale(${counterScale})`;
|
|
329
|
+
ticking = false;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const onScroll = () => {
|
|
333
|
+
if (!ticking) {
|
|
334
|
+
requestAnimationFrame(updateIndicatorWidth);
|
|
335
|
+
ticking = true;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
updateIndicatorWidth();
|
|
340
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
341
|
+
window.addEventListener("resize", updateIndicatorWidth);
|
|
342
|
+
|
|
343
|
+
return () => {
|
|
344
|
+
window.removeEventListener("scroll", onScroll);
|
|
345
|
+
window.removeEventListener("resize", updateIndicatorWidth);
|
|
346
|
+
};
|
|
347
|
+
}, []);
|
|
348
|
+
|
|
349
|
+
// Swipe education tracking
|
|
350
|
+
const SWIPE_TRACKING_KEY = "carousel-swipe-tracking";
|
|
351
|
+
const PDP_VIEWS_BEFORE_PEEK = 10;
|
|
352
|
+
|
|
353
|
+
const getSwipeTracking = useCallback(() => {
|
|
354
|
+
try {
|
|
355
|
+
const stored = localStorage.getItem(SWIPE_TRACKING_KEY);
|
|
356
|
+
if (stored) {
|
|
357
|
+
return JSON.parse(stored) as {
|
|
358
|
+
viewsWithoutSwipe: number;
|
|
359
|
+
hasEverSwiped: boolean;
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
} catch {}
|
|
363
|
+
return { viewsWithoutSwipe: 0, hasEverSwiped: false };
|
|
364
|
+
}, []);
|
|
365
|
+
|
|
366
|
+
const recordSwipe = useCallback(() => {
|
|
367
|
+
if (hasSwipedThisSessionRef.current) return;
|
|
368
|
+
hasSwipedThisSessionRef.current = true;
|
|
369
|
+
try {
|
|
370
|
+
localStorage.setItem(
|
|
371
|
+
SWIPE_TRACKING_KEY,
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
viewsWithoutSwipe: 0,
|
|
374
|
+
hasEverSwiped: true,
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
} catch {}
|
|
378
|
+
}, []);
|
|
379
|
+
|
|
380
|
+
// Peek animation on mount
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!enablePeekAnimation || images.length <= 1) return;
|
|
383
|
+
|
|
384
|
+
const tracking = getSwipeTracking();
|
|
385
|
+
const newViewCount = tracking.viewsWithoutSwipe + 1;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
localStorage.setItem(
|
|
389
|
+
SWIPE_TRACKING_KEY,
|
|
390
|
+
JSON.stringify({
|
|
391
|
+
...tracking,
|
|
392
|
+
viewsWithoutSwipe: newViewCount,
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
} catch {}
|
|
396
|
+
|
|
397
|
+
const shouldShowPeek =
|
|
398
|
+
!tracking.hasEverSwiped && newViewCount >= PDP_VIEWS_BEFORE_PEEK;
|
|
399
|
+
|
|
400
|
+
if (!shouldShowPeek) return;
|
|
401
|
+
|
|
402
|
+
hasShownPeekRef.current = false;
|
|
403
|
+
|
|
404
|
+
let returnTimeout: NodeJS.Timeout;
|
|
405
|
+
let clearTimeout_: NodeJS.Timeout;
|
|
406
|
+
|
|
407
|
+
const startDelay = setTimeout(() => {
|
|
408
|
+
if (hasShownPeekRef.current) return;
|
|
409
|
+
hasShownPeekRef.current = true;
|
|
410
|
+
|
|
411
|
+
setIsPeeking(true);
|
|
412
|
+
setIsTransitioning(true);
|
|
413
|
+
setDragOffset(4);
|
|
414
|
+
|
|
415
|
+
returnTimeout = setTimeout(() => {
|
|
416
|
+
setIsPeeking(false);
|
|
417
|
+
setIsPeekReturning(true);
|
|
418
|
+
setDragOffset(0);
|
|
419
|
+
|
|
420
|
+
clearTimeout_ = setTimeout(() => {
|
|
421
|
+
setIsPeekReturning(false);
|
|
422
|
+
setIsTransitioning(false);
|
|
423
|
+
}, 400);
|
|
424
|
+
}, 500);
|
|
425
|
+
}, 800);
|
|
426
|
+
|
|
427
|
+
return () => {
|
|
428
|
+
clearTimeout(startDelay);
|
|
429
|
+
clearTimeout(returnTimeout);
|
|
430
|
+
clearTimeout(clearTimeout_);
|
|
431
|
+
};
|
|
432
|
+
}, [images.length, getSwipeTracking, enablePeekAnimation]);
|
|
433
|
+
|
|
434
|
+
const wasTapRef = useRef(false);
|
|
435
|
+
|
|
436
|
+
const handleImageTap = useCallback(() => {
|
|
437
|
+
if (!enableZoom) return;
|
|
438
|
+
setViewerKey((prev) => prev + 1);
|
|
439
|
+
setShowImageViewer(true);
|
|
440
|
+
onZoomChange?.(true);
|
|
441
|
+
}, [enableZoom, onZoomChange]);
|
|
442
|
+
|
|
443
|
+
// Shared drag calculation logic
|
|
444
|
+
const calculateDragOffset = useCallback(
|
|
445
|
+
(startX: number, currentX: number, containerWidth: number) => {
|
|
446
|
+
const distance = startX - currentX;
|
|
447
|
+
const offsetPercent = (distance / containerWidth) * 100;
|
|
448
|
+
|
|
449
|
+
const isAtStart = activeIndex === 0;
|
|
450
|
+
const isAtEnd = activeIndex === images.length - 1;
|
|
451
|
+
const isDraggingLeftDir = offsetPercent > 0;
|
|
452
|
+
const isDraggingRightDir = offsetPercent < 0;
|
|
453
|
+
|
|
454
|
+
let clampedOffset = offsetPercent;
|
|
455
|
+
|
|
456
|
+
if (isAtStart && isDraggingRightDir) {
|
|
457
|
+
clampedOffset = Math.max(-25, offsetPercent * 0.5);
|
|
458
|
+
} else if (isAtStart && isDraggingLeftDir) {
|
|
459
|
+
clampedOffset = Math.min(25, offsetPercent * 0.5);
|
|
460
|
+
} else if (isAtEnd && isDraggingLeftDir) {
|
|
461
|
+
clampedOffset = Math.min(25, offsetPercent * 0.5);
|
|
462
|
+
} else if (isAtEnd && isDraggingRightDir) {
|
|
463
|
+
clampedOffset = Math.max(-25, offsetPercent * 0.5);
|
|
464
|
+
} else {
|
|
465
|
+
const maxDrag = 50;
|
|
466
|
+
clampedOffset = Math.max(-maxDrag, Math.min(maxDrag, offsetPercent));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return clampedOffset;
|
|
470
|
+
},
|
|
471
|
+
[activeIndex, images.length]
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Shared swipe completion logic
|
|
475
|
+
const handleSwipeEnd = useCallback(
|
|
476
|
+
(startX: number, endX: number, containerWidth: number) => {
|
|
477
|
+
const distance = startX - endX;
|
|
478
|
+
const distancePercent = (Math.abs(distance) / containerWidth) * 100;
|
|
479
|
+
const snapThreshold = 2;
|
|
480
|
+
|
|
481
|
+
const isLeftSwipe = distance > 0 && distancePercent > snapThreshold;
|
|
482
|
+
const isRightSwipe = distance < 0 && distancePercent > snapThreshold;
|
|
483
|
+
|
|
484
|
+
const canGoNext = activeIndex < images.length - 1;
|
|
485
|
+
const canGoPrev = activeIndex > 0;
|
|
486
|
+
|
|
487
|
+
if (isLeftSwipe && canGoNext) {
|
|
488
|
+
recordSwipe();
|
|
489
|
+
setIsTransitioning(true);
|
|
490
|
+
setActiveIndex(activeIndex + 1);
|
|
491
|
+
setDragOffset(0);
|
|
492
|
+
onIndexChange?.(activeIndex + 1);
|
|
493
|
+
setTimeout(() => setIsTransitioning(false), 300);
|
|
494
|
+
} else if (isRightSwipe && canGoPrev) {
|
|
495
|
+
recordSwipe();
|
|
496
|
+
setIsTransitioning(true);
|
|
497
|
+
setActiveIndex(activeIndex - 1);
|
|
498
|
+
setDragOffset(0);
|
|
499
|
+
onIndexChange?.(activeIndex - 1);
|
|
500
|
+
setTimeout(() => setIsTransitioning(false), 300);
|
|
501
|
+
} else {
|
|
502
|
+
setIsTransitioning(true);
|
|
503
|
+
setDragOffset(0);
|
|
504
|
+
setTimeout(() => setIsTransitioning(false), 300);
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
[activeIndex, images.length, onIndexChange, recordSwipe]
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Touch handlers
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
const container = carouselRef.current;
|
|
513
|
+
if (!container) return;
|
|
514
|
+
|
|
515
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
516
|
+
setIsPeeking(false);
|
|
517
|
+
setDragOffset(0);
|
|
518
|
+
|
|
519
|
+
const touch = e.touches[0];
|
|
520
|
+
touchStartRef.current = {
|
|
521
|
+
x: touch.clientX,
|
|
522
|
+
y: touch.clientY,
|
|
523
|
+
time: Date.now(),
|
|
524
|
+
};
|
|
525
|
+
horizontalGestureRef.current = false;
|
|
526
|
+
wasTapRef.current = true;
|
|
527
|
+
setTouchStart({ x: touch.clientX, y: touch.clientY });
|
|
528
|
+
setTouchEnd(null);
|
|
529
|
+
setIsDragging(false);
|
|
530
|
+
setIsTransitioning(false);
|
|
531
|
+
setIsHorizontalSwipe(null);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
535
|
+
if (!touchStartRef.current) return;
|
|
536
|
+
|
|
537
|
+
const touch = e.touches[0];
|
|
538
|
+
const deltaX = Math.abs(touch.clientX - touchStartRef.current.x);
|
|
539
|
+
const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
|
|
540
|
+
|
|
541
|
+
if (deltaX > 10 || deltaY > 10) {
|
|
542
|
+
wasTapRef.current = false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (
|
|
546
|
+
!horizontalGestureRef.current &&
|
|
547
|
+
deltaX > 1 &&
|
|
548
|
+
deltaX > deltaY * 0.75
|
|
549
|
+
) {
|
|
550
|
+
horizontalGestureRef.current = true;
|
|
551
|
+
setIsHorizontalSwipe(true);
|
|
552
|
+
setIsDragging(true);
|
|
553
|
+
e.preventDefault();
|
|
554
|
+
e.stopPropagation();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (horizontalGestureRef.current) {
|
|
558
|
+
e.preventDefault();
|
|
559
|
+
e.stopPropagation();
|
|
560
|
+
|
|
561
|
+
setTouchEnd({ x: touch.clientX, y: touch.clientY });
|
|
562
|
+
const containerWidth = container?.offsetWidth || 0;
|
|
563
|
+
const clampedOffset = calculateDragOffset(
|
|
564
|
+
touchStartRef.current.x,
|
|
565
|
+
touch.clientX,
|
|
566
|
+
containerWidth
|
|
567
|
+
);
|
|
568
|
+
setDragOffset(clampedOffset);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const handleTouchEnd = () => {
|
|
573
|
+
// Tap detection moved to React onClick handler for iOS Safari compatibility
|
|
574
|
+
// wasTapRef.current is still set/cleared here for onClick to check
|
|
575
|
+
|
|
576
|
+
if (!touchStartRef.current) {
|
|
577
|
+
horizontalGestureRef.current = false;
|
|
578
|
+
setIsDragging(false);
|
|
579
|
+
setDragOffset(0);
|
|
580
|
+
setIsHorizontalSwipe(null);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (horizontalGestureRef.current && touchEnd) {
|
|
585
|
+
const containerWidth = container?.offsetWidth || 0;
|
|
586
|
+
handleSwipeEnd(touchStartRef.current.x, touchEnd.x, containerWidth);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
touchStartRef.current = null;
|
|
590
|
+
horizontalGestureRef.current = false;
|
|
591
|
+
// Don't reset wasTapRef here - onClick fires AFTER touchend and needs to read it
|
|
592
|
+
setIsDragging(false);
|
|
593
|
+
setIsHorizontalSwipe(null);
|
|
594
|
+
if (!horizontalGestureRef.current) {
|
|
595
|
+
setDragOffset(0);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
container.addEventListener("touchstart", handleTouchStart, {
|
|
600
|
+
passive: true,
|
|
601
|
+
});
|
|
602
|
+
container.addEventListener("touchmove", handleTouchMove, {
|
|
603
|
+
passive: false,
|
|
604
|
+
});
|
|
605
|
+
container.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
606
|
+
|
|
607
|
+
return () => {
|
|
608
|
+
container.removeEventListener("touchstart", handleTouchStart);
|
|
609
|
+
container.removeEventListener("touchmove", handleTouchMove);
|
|
610
|
+
container.removeEventListener("touchend", handleTouchEnd);
|
|
611
|
+
};
|
|
612
|
+
}, [
|
|
613
|
+
activeIndex,
|
|
614
|
+
touchEnd,
|
|
615
|
+
images.length,
|
|
616
|
+
onIndexChange,
|
|
617
|
+
calculateDragOffset,
|
|
618
|
+
handleSwipeEnd,
|
|
619
|
+
handleImageTap,
|
|
620
|
+
]);
|
|
621
|
+
|
|
622
|
+
// Mouse handlers for desktop
|
|
623
|
+
const mouseStartRef = useRef<{ x: number; y: number; time: number } | null>(
|
|
624
|
+
null
|
|
625
|
+
);
|
|
626
|
+
const isMouseDraggingRef = useRef(false);
|
|
627
|
+
|
|
628
|
+
useEffect(() => {
|
|
629
|
+
const container = carouselRef.current;
|
|
630
|
+
if (!container) return;
|
|
631
|
+
|
|
632
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
633
|
+
if (e.button !== 0) return;
|
|
634
|
+
|
|
635
|
+
setIsPeeking(false);
|
|
636
|
+
setDragOffset(0);
|
|
637
|
+
|
|
638
|
+
mouseStartRef.current = {
|
|
639
|
+
x: e.clientX,
|
|
640
|
+
y: e.clientY,
|
|
641
|
+
time: Date.now(),
|
|
642
|
+
};
|
|
643
|
+
isMouseDraggingRef.current = false;
|
|
644
|
+
wasTapRef.current = true;
|
|
645
|
+
setIsDragging(false);
|
|
646
|
+
setIsTransitioning(false);
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
650
|
+
if (!mouseStartRef.current) return;
|
|
651
|
+
|
|
652
|
+
const deltaX = Math.abs(e.clientX - mouseStartRef.current.x);
|
|
653
|
+
const deltaY = Math.abs(e.clientY - mouseStartRef.current.y);
|
|
654
|
+
|
|
655
|
+
if (deltaX > 5 || deltaY > 5) {
|
|
656
|
+
wasTapRef.current = false;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!isMouseDraggingRef.current && deltaX > 5 && deltaX > deltaY) {
|
|
660
|
+
isMouseDraggingRef.current = true;
|
|
661
|
+
setIsDragging(true);
|
|
662
|
+
e.preventDefault();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (isMouseDraggingRef.current) {
|
|
666
|
+
e.preventDefault();
|
|
667
|
+
const containerWidth = container?.offsetWidth || 0;
|
|
668
|
+
const clampedOffset = calculateDragOffset(
|
|
669
|
+
mouseStartRef.current.x,
|
|
670
|
+
e.clientX,
|
|
671
|
+
containerWidth
|
|
672
|
+
);
|
|
673
|
+
setDragOffset(clampedOffset);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const handleMouseUp = (e: MouseEvent) => {
|
|
678
|
+
// Tap detection moved to React onClick handler for consistency with touch
|
|
679
|
+
// wasTapRef.current is still set/cleared here for onClick to check
|
|
680
|
+
|
|
681
|
+
if (!mouseStartRef.current) {
|
|
682
|
+
isMouseDraggingRef.current = false;
|
|
683
|
+
setIsDragging(false);
|
|
684
|
+
setDragOffset(0);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (isMouseDraggingRef.current) {
|
|
689
|
+
const containerWidth = container?.offsetWidth || 0;
|
|
690
|
+
handleSwipeEnd(mouseStartRef.current.x, e.clientX, containerWidth);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
mouseStartRef.current = null;
|
|
694
|
+
isMouseDraggingRef.current = false;
|
|
695
|
+
// Don't reset wasTapRef here - onClick fires AFTER mouseup and needs to read it
|
|
696
|
+
setIsDragging(false);
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const handleMouseLeave = () => {
|
|
700
|
+
if (isMouseDraggingRef.current) {
|
|
701
|
+
setIsTransitioning(true);
|
|
702
|
+
setDragOffset(0);
|
|
703
|
+
setTimeout(() => setIsTransitioning(false), 300);
|
|
704
|
+
}
|
|
705
|
+
mouseStartRef.current = null;
|
|
706
|
+
isMouseDraggingRef.current = false;
|
|
707
|
+
// wasTapRef reset happens on next mousedown/touchstart
|
|
708
|
+
setIsDragging(false);
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
container.addEventListener("mousedown", handleMouseDown);
|
|
712
|
+
container.addEventListener("mousemove", handleMouseMove);
|
|
713
|
+
container.addEventListener("mouseup", handleMouseUp);
|
|
714
|
+
container.addEventListener("mouseleave", handleMouseLeave);
|
|
715
|
+
|
|
716
|
+
return () => {
|
|
717
|
+
container.removeEventListener("mousedown", handleMouseDown);
|
|
718
|
+
container.removeEventListener("mousemove", handleMouseMove);
|
|
719
|
+
container.removeEventListener("mouseup", handleMouseUp);
|
|
720
|
+
container.removeEventListener("mouseleave", handleMouseLeave);
|
|
721
|
+
};
|
|
722
|
+
}, [calculateDragOffset, handleSwipeEnd, handleImageTap]);
|
|
723
|
+
|
|
724
|
+
const goToSlide = useCallback(
|
|
725
|
+
(index: number, withTransition = true) => {
|
|
726
|
+
if (withTransition) {
|
|
727
|
+
setIsTransitioning(true);
|
|
728
|
+
setDragOffset(0);
|
|
729
|
+
setTimeout(() => {
|
|
730
|
+
setActiveIndex(index);
|
|
731
|
+
onIndexChange?.(index);
|
|
732
|
+
}, 50);
|
|
733
|
+
setTimeout(() => setIsTransitioning(false), 350);
|
|
734
|
+
} else {
|
|
735
|
+
setActiveIndex(index);
|
|
736
|
+
setDragOffset(0);
|
|
737
|
+
onIndexChange?.(index);
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
[onIndexChange]
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
// Register indicator with sticky hero context
|
|
744
|
+
useEffect(() => {
|
|
745
|
+
if (stickyHeroContext?.setCarouselIndicator && images.length > 1) {
|
|
746
|
+
const indicator = (
|
|
747
|
+
<div
|
|
748
|
+
className="w-full"
|
|
749
|
+
role="group"
|
|
750
|
+
aria-label="Product image navigation"
|
|
751
|
+
>
|
|
752
|
+
<div className="relative h-1">
|
|
753
|
+
<div className="flex h-full">
|
|
754
|
+
{images.map((_, index) => (
|
|
755
|
+
<button
|
|
756
|
+
key={index}
|
|
757
|
+
onClick={() => goToSlide(index)}
|
|
758
|
+
className="flex-1 bg-transparent hover:bg-gray-200 transition-colors duration-150"
|
|
759
|
+
aria-label={`Go to image ${index + 1} of ${images.length}`}
|
|
760
|
+
aria-current={activeIndex === index ? "true" : undefined}
|
|
761
|
+
/>
|
|
762
|
+
))}
|
|
763
|
+
</div>
|
|
764
|
+
<div
|
|
765
|
+
className="absolute top-0 h-full bg-primary transition-all duration-300 ease-out"
|
|
766
|
+
style={{
|
|
767
|
+
width: `${100 / images.length}%`,
|
|
768
|
+
left: `${(activeIndex * 100) / images.length}%`,
|
|
769
|
+
}}
|
|
770
|
+
aria-hidden="true"
|
|
771
|
+
/>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
stickyHeroContext.setCarouselIndicator(indicator);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return () => {
|
|
780
|
+
if (stickyHeroContext?.setCarouselIndicator) {
|
|
781
|
+
stickyHeroContext.setCarouselIndicator(null);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}, [activeIndex, images.length, goToSlide, stickyHeroContext]);
|
|
785
|
+
|
|
786
|
+
if (!images || images.length === 0) {
|
|
787
|
+
return <div className="w-full aspect-video bg-muted rounded-lg" />;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return (
|
|
791
|
+
<div className={`relative w-full h-full ${className}`}>
|
|
792
|
+
{/* Main carousel container */}
|
|
793
|
+
<div
|
|
794
|
+
ref={carouselRef}
|
|
795
|
+
className="relative z-20 w-full h-full overflow-hidden select-none"
|
|
796
|
+
style={{
|
|
797
|
+
touchAction: "pan-y pinch-zoom",
|
|
798
|
+
cursor: isDragging ? "grabbing" : "grab",
|
|
799
|
+
}}
|
|
800
|
+
onClick={() => {
|
|
801
|
+
if (wasTapRef.current && enableZoom) {
|
|
802
|
+
handleImageTap();
|
|
803
|
+
}
|
|
804
|
+
wasTapRef.current = false;
|
|
805
|
+
}}
|
|
806
|
+
role="region"
|
|
807
|
+
aria-roledescription="carousel"
|
|
808
|
+
aria-label="Product images"
|
|
809
|
+
>
|
|
810
|
+
{/* Images container */}
|
|
811
|
+
<div
|
|
812
|
+
className={`flex h-full ${
|
|
813
|
+
isPeekReturning
|
|
814
|
+
? "transition-transform duration-300 ease-[cubic-bezier(0,0,0.2,1)]"
|
|
815
|
+
: isPeeking
|
|
816
|
+
? "transition-transform duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
817
|
+
: isTransitioning || !isDragging
|
|
818
|
+
? "transition-transform duration-300 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"
|
|
819
|
+
: ""
|
|
820
|
+
}`}
|
|
821
|
+
style={{
|
|
822
|
+
transform: `translateX(${-(activeIndex * 100) - dragOffset}%)`,
|
|
823
|
+
}}
|
|
824
|
+
>
|
|
825
|
+
{images.map((image, index) => {
|
|
826
|
+
// MEMORY OPTIMIZATION: Only decode images within ±1 slide of current
|
|
827
|
+
// This prevents GPU memory from accumulating for off-screen images
|
|
828
|
+
// Critical for iOS Safari which has strict memory limits
|
|
829
|
+
const DECODE_WINDOW = 1;
|
|
830
|
+
const isWithinDecodeWindow =
|
|
831
|
+
Math.abs(index - activeIndex) <= DECODE_WINDOW;
|
|
832
|
+
|
|
833
|
+
return (
|
|
834
|
+
<div
|
|
835
|
+
key={index}
|
|
836
|
+
className="w-full h-full flex-shrink-0 relative select-none"
|
|
837
|
+
>
|
|
838
|
+
{!isWithinDecodeWindow ? (
|
|
839
|
+
<div className="w-full h-full bg-muted" />
|
|
840
|
+
) : image.isPlaceholder ? (
|
|
841
|
+
<div className="w-full h-full bg-muted-foreground/20 animate-pulse" />
|
|
842
|
+
) : image.isRealMockup && currentArtwork ? (
|
|
843
|
+
<HeroProductImage
|
|
844
|
+
productId={productId}
|
|
845
|
+
artwork={currentArtwork}
|
|
846
|
+
placement={image.placement}
|
|
847
|
+
mockupId={image.mockupId}
|
|
848
|
+
width={mockupWidth}
|
|
849
|
+
realtimeUrl={
|
|
850
|
+
image.mockupId
|
|
851
|
+
? realtimeMockupUrls[image.mockupId] ??
|
|
852
|
+
generatedUrlsRef.current.get(image.mockupId) ??
|
|
853
|
+
undefined
|
|
854
|
+
: undefined
|
|
855
|
+
}
|
|
856
|
+
onUrlGenerated={
|
|
857
|
+
image.mockupId
|
|
858
|
+
? (url) => handleUrlGenerated(image.mockupId!, url)
|
|
859
|
+
: undefined
|
|
860
|
+
}
|
|
861
|
+
className="w-full h-full object-cover cursor-pointer pointer-events-none"
|
|
862
|
+
/>
|
|
863
|
+
) : image.isRealMockup && !currentArtwork ? (
|
|
864
|
+
<div className="w-full h-full bg-muted animate-pulse" />
|
|
865
|
+
) : (
|
|
866
|
+
<img
|
|
867
|
+
src={image.src}
|
|
868
|
+
alt={image.label}
|
|
869
|
+
crossOrigin="anonymous"
|
|
870
|
+
className="w-full h-full object-cover cursor-pointer pointer-events-none"
|
|
871
|
+
loading="lazy"
|
|
872
|
+
decoding="async"
|
|
873
|
+
draggable={false}
|
|
874
|
+
/>
|
|
875
|
+
)}
|
|
876
|
+
</div>
|
|
877
|
+
);
|
|
878
|
+
})}
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
{/* Progress bar indicator */}
|
|
883
|
+
{showIndicators && images.length > 1 && (
|
|
884
|
+
<div
|
|
885
|
+
ref={indicatorRef}
|
|
886
|
+
data-carousel-indicator
|
|
887
|
+
className="relative z-20 w-full"
|
|
888
|
+
role="group"
|
|
889
|
+
aria-label="Product image navigation"
|
|
890
|
+
>
|
|
891
|
+
<div className="relative h-1">
|
|
892
|
+
<div className="flex h-full absolute inset-0">
|
|
893
|
+
{images.map((_, index) => (
|
|
894
|
+
<button
|
|
895
|
+
key={index}
|
|
896
|
+
onClick={() => goToSlide(index)}
|
|
897
|
+
className="flex-1 bg-transparent"
|
|
898
|
+
aria-label={`Go to image ${index + 1} of ${images.length}`}
|
|
899
|
+
aria-current={activeIndex === index ? "true" : undefined}
|
|
900
|
+
/>
|
|
901
|
+
))}
|
|
902
|
+
</div>
|
|
903
|
+
<div
|
|
904
|
+
className={`absolute top-0 h-full bg-foreground ${
|
|
905
|
+
isPeekReturning
|
|
906
|
+
? "transition-all duration-300 ease-[cubic-bezier(0,0,0.2,1)]"
|
|
907
|
+
: isPeeking
|
|
908
|
+
? "transition-all duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
909
|
+
: isDragging
|
|
910
|
+
? ""
|
|
911
|
+
: "transition-all duration-300 ease-out"
|
|
912
|
+
}`}
|
|
913
|
+
style={{
|
|
914
|
+
width: `${100 / images.length}%`,
|
|
915
|
+
left: `${(activeIndex * 100 + dragOffset) / images.length}%`,
|
|
916
|
+
}}
|
|
917
|
+
aria-hidden="true"
|
|
918
|
+
/>
|
|
919
|
+
</div>
|
|
920
|
+
</div>
|
|
921
|
+
)}
|
|
922
|
+
|
|
923
|
+
{/* Enhanced Image Viewer */}
|
|
924
|
+
{enableZoom &&
|
|
925
|
+
showImageViewer &&
|
|
926
|
+
(() => {
|
|
927
|
+
const currentImage = imagesWithRealtimeUrls[activeIndex];
|
|
928
|
+
|
|
929
|
+
// For real mockups, also check generatedUrlsRef which stores URLs from HeroProductImage
|
|
930
|
+
let imageUrl = currentImage?.src || "";
|
|
931
|
+
if (!imageUrl && currentImage?.isRealMockup && currentImage?.mockupId) {
|
|
932
|
+
imageUrl = generatedUrlsRef.current.get(currentImage.mockupId) || "";
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!imageUrl) {
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Convert to ZoomImage format, resolving URLs from all sources
|
|
940
|
+
const zoomImages = imagesWithRealtimeUrls
|
|
941
|
+
.map((img) => {
|
|
942
|
+
let src = img.src || "";
|
|
943
|
+
if (!src && img.isRealMockup && img.mockupId) {
|
|
944
|
+
src = generatedUrlsRef.current.get(img.mockupId) || "";
|
|
945
|
+
}
|
|
946
|
+
return src ? {
|
|
947
|
+
src,
|
|
948
|
+
alt: img.label,
|
|
949
|
+
naturalWidth: img.naturalWidth,
|
|
950
|
+
naturalHeight: img.naturalHeight,
|
|
951
|
+
} : null;
|
|
952
|
+
})
|
|
953
|
+
.filter((img): img is NonNullable<typeof img> => img !== null);
|
|
954
|
+
|
|
955
|
+
return (
|
|
956
|
+
<EnhancedImageViewer
|
|
957
|
+
key={viewerKey}
|
|
958
|
+
imageUrl={imageUrl}
|
|
959
|
+
alt={currentImage?.label}
|
|
960
|
+
onClose={() => { setShowImageViewer(false); onZoomChange?.(false); }}
|
|
961
|
+
images={zoomImages}
|
|
962
|
+
currentIndex={activeIndex}
|
|
963
|
+
onIndexChange={(newIndex) => {
|
|
964
|
+
setActiveIndex(newIndex);
|
|
965
|
+
onIndexChange?.(newIndex);
|
|
966
|
+
}}
|
|
967
|
+
/>
|
|
968
|
+
);
|
|
969
|
+
})()}
|
|
970
|
+
</div>
|
|
971
|
+
);
|
|
972
|
+
}, (prevProps, nextProps) => {
|
|
973
|
+
// Custom comparison for memoization
|
|
974
|
+
// Check primitive props
|
|
975
|
+
if (prevProps.currentIndex !== nextProps.currentIndex) return false;
|
|
976
|
+
if (prevProps.productId !== nextProps.productId) return false;
|
|
977
|
+
if (prevProps.className !== nextProps.className) return false;
|
|
978
|
+
if (prevProps.enablePeekAnimation !== nextProps.enablePeekAnimation) return false;
|
|
979
|
+
if (prevProps.enableZoom !== nextProps.enableZoom) return false;
|
|
980
|
+
if (prevProps.showIndicators !== nextProps.showIndicators) return false;
|
|
981
|
+
|
|
982
|
+
// Check artwork (compare by src to avoid object reference issues)
|
|
983
|
+
if (prevProps.currentArtwork?.src !== nextProps.currentArtwork?.src) return false;
|
|
984
|
+
if (prevProps.currentArtwork?.type !== nextProps.currentArtwork?.type) return false;
|
|
985
|
+
|
|
986
|
+
// Check images array length (assume content is stable if length matches)
|
|
987
|
+
if (prevProps.images.length !== nextProps.images.length) return false;
|
|
988
|
+
|
|
989
|
+
// Shallow check image sources for changes
|
|
990
|
+
for (let i = 0; i < prevProps.images.length; i++) {
|
|
991
|
+
if (prevProps.images[i]?.src !== nextProps.images[i]?.src) return false;
|
|
992
|
+
if (prevProps.images[i]?.mockupId !== nextProps.images[i]?.mockupId) return false;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Callbacks are typically stable (same function reference)
|
|
996
|
+
// Only check if they changed from defined to undefined or vice versa
|
|
997
|
+
if (!!prevProps.onIndexChange !== !!nextProps.onIndexChange) return false;
|
|
998
|
+
|
|
999
|
+
return true;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
export default MobileProductCarousel;
|