@snowcone-app/ui 0.1.42 → 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.
Files changed (192) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. 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;