@snowcone-app/ui 0.1.43 → 0.2.1

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 (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. package/dist/styles.css +0 -1
@@ -0,0 +1,505 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ useState,
5
+ useRef,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useCallback,
9
+ type ComponentType,
10
+ } from "react";
11
+ import { createPortal } from "react-dom";
12
+ import {
13
+ TransformWrapper as OriginalTransformWrapper,
14
+ TransformComponent as OriginalTransformComponent,
15
+ } from "react-zoom-pan-pinch";
16
+ import type {
17
+ ReactZoomPanPinchContentRef,
18
+ ReactZoomPanPinchProps,
19
+ } from "react-zoom-pan-pinch";
20
+ import { X } from "lucide-react";
21
+ import type { ZoomImage } from "./types";
22
+
23
+ // Cast to fix React 19 type compatibility with react-zoom-pan-pinch
24
+ const TransformWrapper = OriginalTransformWrapper as ComponentType<
25
+ Omit<ReactZoomPanPinchProps, "ref"> & {
26
+ ref?: React.Ref<ReactZoomPanPinchContentRef>;
27
+ }
28
+ >;
29
+ const TransformComponent = OriginalTransformComponent as ComponentType<{
30
+ children?: React.ReactNode;
31
+ wrapperStyle?: React.CSSProperties;
32
+ }>;
33
+
34
+ interface EnhancedImageViewerProps {
35
+ imageUrl: string;
36
+ alt: string;
37
+ onClose: () => void;
38
+ images: ZoomImage[];
39
+ onIndexChange?: (index: number) => void;
40
+ currentIndex?: number; // Add explicit current index prop
41
+ }
42
+
43
+ // Enhanced Image Viewer Component with react-zoom-pan-pinch (On.com style)
44
+ export const EnhancedImageViewer = ({
45
+ imageUrl,
46
+ alt,
47
+ onClose,
48
+ images,
49
+ onIndexChange,
50
+ currentIndex: propCurrentIndex,
51
+ }: EnhancedImageViewerProps) => {
52
+ const [isMounted, setIsMounted] = useState(false);
53
+ const [isClient, setIsClient] = useState(false);
54
+ const [scale, setScale] = useState({ initial: 1, min: 1, max: 8 });
55
+ const [cropDimensions, setCropDimensions] = useState({ width: 0, height: 0 });
56
+ const [currentZoomScale, setCurrentZoomScale] = useState(1);
57
+ const imgRef = useRef<HTMLImageElement>(null);
58
+
59
+ // Swipe detection refs
60
+ const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(
61
+ null
62
+ );
63
+ const isSwipingRef = useRef(false);
64
+
65
+ // Calculate scale with crop matching viewport aspect ratio
66
+ const calculateScale = useCallback(() => {
67
+ const viewport = {
68
+ width: window.innerWidth,
69
+ height: window.innerHeight,
70
+ };
71
+
72
+ // Crop to viewport aspect ratio - image will fill screen exactly
73
+ setCropDimensions({ width: viewport.width, height: viewport.height });
74
+
75
+ // Since crop matches viewport, initial scale is 1 (no scaling needed)
76
+ setScale({
77
+ initial: 1,
78
+ min: 1,
79
+ max: 8,
80
+ });
81
+ }, []);
82
+
83
+ const handleImageLoad = useCallback(() => {
84
+ calculateScale();
85
+ }, [calculateScale]);
86
+
87
+ // Mark as client-side and calculate initial scale
88
+ useLayoutEffect(() => {
89
+ setIsClient(true);
90
+ setIsMounted(true);
91
+ document.body.style.overflow = "hidden";
92
+
93
+ // Calculate scale and crop dimensions
94
+ calculateScale();
95
+
96
+ return () => {
97
+ document.body.style.overflow = "";
98
+ };
99
+ }, [calculateScale]);
100
+
101
+ useEffect(() => {
102
+ calculateScale();
103
+ }, [calculateScale]);
104
+
105
+ useEffect(() => {
106
+ const handleResize = () => {
107
+ calculateScale();
108
+ };
109
+
110
+ window.addEventListener("resize", handleResize);
111
+ return () => window.removeEventListener("resize", handleResize);
112
+ }, [calculateScale]);
113
+
114
+ // Keyboard navigation
115
+ useEffect(() => {
116
+ const handleKeyDown = (e: KeyboardEvent) => {
117
+ if (e.key === "Escape") {
118
+ onClose();
119
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
120
+ // Find current index
121
+ let currentIndex =
122
+ propCurrentIndex !== undefined ? propCurrentIndex : -1;
123
+ if (currentIndex === -1) {
124
+ currentIndex = images.findIndex((img) => img.src === imageUrl);
125
+ }
126
+ if (currentIndex === -1) currentIndex = 0;
127
+
128
+ // Navigate to next/previous
129
+ if (e.key === "ArrowLeft" && currentIndex > 0) {
130
+ const prevImage = images[currentIndex - 1];
131
+ if (prevImage?.src) {
132
+ onIndexChange?.(currentIndex - 1);
133
+ }
134
+ } else if (e.key === "ArrowRight" && currentIndex < images.length - 1) {
135
+ const nextImage = images[currentIndex + 1];
136
+ if (nextImage?.src) {
137
+ onIndexChange?.(currentIndex + 1);
138
+ }
139
+ }
140
+ }
141
+ };
142
+ document.addEventListener("keydown", handleKeyDown);
143
+ return () => document.removeEventListener("keydown", handleKeyDown);
144
+ }, [onClose, imageUrl, images, onIndexChange, propCurrentIndex]);
145
+
146
+ // Swipe navigation - only when at initial zoom (not zoomed in)
147
+ const handleSwipeNavigation = useCallback(
148
+ (direction: "left" | "right") => {
149
+ let currentIndex = propCurrentIndex !== undefined ? propCurrentIndex : -1;
150
+ if (currentIndex === -1) {
151
+ currentIndex = images.findIndex((img) => img.src === imageUrl);
152
+ }
153
+ if (currentIndex === -1) currentIndex = 0;
154
+
155
+ if (direction === "left" && currentIndex < images.length - 1) {
156
+ // Swipe left = next image
157
+ const nextImage = images[currentIndex + 1];
158
+ if (nextImage?.src || nextImage?.isRealMockup) {
159
+ onIndexChange?.(currentIndex + 1);
160
+ }
161
+ } else if (direction === "right" && currentIndex > 0) {
162
+ // Swipe right = previous image
163
+ const prevImage = images[currentIndex - 1];
164
+ if (prevImage?.src || prevImage?.isRealMockup) {
165
+ onIndexChange?.(currentIndex - 1);
166
+ }
167
+ }
168
+ },
169
+ [propCurrentIndex, images, imageUrl, onIndexChange]
170
+ );
171
+
172
+ // Touch handlers for swipe detection
173
+ const handleTouchStart = useCallback(
174
+ (e: React.TouchEvent) => {
175
+ // Only track swipe when at initial zoom level
176
+ const isAtInitialZoom = Math.abs(currentZoomScale - scale.initial) < 0.1;
177
+ if (!isAtInitialZoom) return;
178
+
179
+ const touch = e.touches[0];
180
+ touchStartRef.current = {
181
+ x: touch.clientX,
182
+ y: touch.clientY,
183
+ time: Date.now(),
184
+ };
185
+ isSwipingRef.current = false;
186
+ },
187
+ [currentZoomScale, scale.initial]
188
+ );
189
+
190
+ const handleTouchEnd = useCallback(
191
+ (e: React.TouchEvent) => {
192
+ if (!touchStartRef.current) return;
193
+
194
+ const touch = e.changedTouches[0];
195
+ const deltaX = touch.clientX - touchStartRef.current.x;
196
+ const deltaY = touch.clientY - touchStartRef.current.y;
197
+ const elapsed = Date.now() - touchStartRef.current.time;
198
+
199
+ // Swipe detection: horizontal movement > 50px, more horizontal than vertical, < 300ms
200
+ const minSwipeDistance = 50;
201
+ const isHorizontalSwipe = Math.abs(deltaX) > Math.abs(deltaY) * 1.5;
202
+ const isFastEnough = elapsed < 300;
203
+ const isLongEnough = Math.abs(deltaX) > minSwipeDistance;
204
+
205
+ if (isHorizontalSwipe && isFastEnough && isLongEnough) {
206
+ if (deltaX < 0) {
207
+ handleSwipeNavigation("left");
208
+ } else {
209
+ handleSwipeNavigation("right");
210
+ }
211
+ }
212
+
213
+ touchStartRef.current = null;
214
+ },
215
+ [handleSwipeNavigation]
216
+ );
217
+
218
+ if (!isMounted || !isClient) {
219
+ return createPortal(
220
+ <div
221
+ className="fixed inset-0 bg-black flex items-center justify-center"
222
+ style={{ zIndex: 999999 }}
223
+ >
224
+ <div
225
+ style={{
226
+ color: "white",
227
+ fontSize: "18px",
228
+ }}
229
+ >
230
+ Loading...
231
+ </div>
232
+ </div>,
233
+ document.body
234
+ );
235
+ }
236
+
237
+ return createPortal(
238
+ <div
239
+ className="fixed inset-0 bg-black flex items-center justify-center"
240
+ style={{ zIndex: 999999 }}
241
+ role="dialog"
242
+ aria-modal="true"
243
+ aria-label="Image viewer"
244
+ >
245
+ {/* Close button — bottom right for reachability */}
246
+ <button
247
+ onClick={(e) => { e.stopPropagation(); onClose(); }}
248
+ aria-label="Close"
249
+ style={{
250
+ position: "fixed",
251
+ bottom: 12,
252
+ right: 24,
253
+ zIndex: 9999999,
254
+ width: 56,
255
+ height: 56,
256
+ borderRadius: "50%",
257
+ background: "white",
258
+ color: "black",
259
+ border: "none",
260
+ display: "flex",
261
+ alignItems: "center",
262
+ justifyContent: "center",
263
+ cursor: "pointer",
264
+ touchAction: "manipulation",
265
+ marginBottom: "env(safe-area-inset-bottom, 0px)",
266
+ }}
267
+ >
268
+ <X className="w-7 h-7" strokeWidth={2.5} />
269
+ </button>
270
+
271
+ {/* Pinch-to-zoom with contain mode (image fully visible initially) */}
272
+ <TransformWrapper
273
+ key={`${scale.initial}-${scale.min}-${scale.max}`}
274
+ initialScale={scale.initial}
275
+ minScale={scale.min}
276
+ maxScale={scale.max}
277
+ limitToBounds={true}
278
+ centerOnInit={true}
279
+ doubleClick={{
280
+ excluded: [],
281
+ step: 0.7,
282
+ }}
283
+ panning={{
284
+ activationKeys: [],
285
+ excluded: [],
286
+ // Disable panning at initial zoom level (nothing to pan to)
287
+ disabled: Math.abs(currentZoomScale - scale.initial) < 0.1,
288
+ }}
289
+ pinch={{
290
+ excluded: [],
291
+ step: 5,
292
+ }}
293
+ wheel={{
294
+ activationKeys: [],
295
+ excluded: [],
296
+ smoothStep: 0.001,
297
+ step: 0.2,
298
+ }}
299
+ onInit={(ref) => {
300
+ setCurrentZoomScale(ref.state.scale);
301
+ }}
302
+ onTransformed={(ref) => {
303
+ setCurrentZoomScale(ref.state.scale);
304
+ }}
305
+ >
306
+ {({ zoomIn, zoomOut, resetTransform }) => (
307
+ <div
308
+ style={{
309
+ width: "100vw",
310
+ height: "100vh",
311
+ display: "flex",
312
+ alignItems: "center",
313
+ justifyContent: "center",
314
+ }}
315
+ onTouchStart={handleTouchStart}
316
+ onTouchEnd={handleTouchEnd}
317
+ >
318
+ <TransformComponent
319
+ wrapperStyle={{
320
+ width: "100%",
321
+ height: "100%",
322
+ }}
323
+ >
324
+ {/* Crop container matching viewport aspect ratio */}
325
+ <div
326
+ style={{
327
+ width: cropDimensions.width || "100vw",
328
+ height: cropDimensions.height || "100vh",
329
+ overflow: "hidden",
330
+ display: "flex",
331
+ alignItems: "center",
332
+ justifyContent: "center",
333
+ }}
334
+ >
335
+ <img
336
+ ref={imgRef}
337
+ src={imageUrl}
338
+ alt={alt}
339
+ crossOrigin="anonymous"
340
+ key={`${imageUrl}-${propCurrentIndex}`}
341
+ onLoad={handleImageLoad}
342
+ style={{
343
+ width: "100%",
344
+ height: "100%",
345
+ objectFit: "cover", // Crop 16:9 to fit 4:3 container
346
+ }}
347
+ />
348
+ </div>
349
+ </TransformComponent>
350
+
351
+ {/* Navigation controls - buttons centered, counter bottom-left */}
352
+ <div className="fixed bottom-5 left-0 right-0 z-50">
353
+ {/* Centered prev/next buttons */}
354
+ <div className="flex justify-center gap-2">
355
+ {(() => {
356
+ // Use the explicit currentIndex prop if provided, otherwise try to find it
357
+ let currentIndex =
358
+ propCurrentIndex !== undefined ? propCurrentIndex : -1;
359
+
360
+ // If no prop provided, find current index by matching imageUrl with img.src
361
+ if (currentIndex === -1) {
362
+ currentIndex = images.findIndex(
363
+ (img) => img.src === imageUrl
364
+ );
365
+ }
366
+
367
+ // If not found by src match, try to find by isRealMockup and placement for real mockups
368
+ if (currentIndex === -1) {
369
+ currentIndex = images.findIndex(
370
+ (img) => img.isRealMockup && img.src === imageUrl
371
+ );
372
+ }
373
+
374
+ // If still not found, find by exact object match
375
+ if (currentIndex === -1) {
376
+ currentIndex = images.findIndex(
377
+ (img) =>
378
+ img.src === imageUrl ||
379
+ (img.isRealMockup && !img.src && imageUrl === "") ||
380
+ (img.src === "" && imageUrl === "")
381
+ );
382
+ }
383
+
384
+ // Fallback: assume we're at index 0 if we can't find the image
385
+ if (currentIndex === -1) {
386
+ currentIndex = 0;
387
+ }
388
+
389
+ const isFirst = currentIndex === 0;
390
+ const isLast = currentIndex === images.length - 1;
391
+
392
+ // Find next/previous available images (skip images without src unless they're real mockups with generated URLs)
393
+ const findNextAvailableIndex = (
394
+ fromIndex: number,
395
+ direction: 1 | -1
396
+ ): number => {
397
+ for (
398
+ let i = fromIndex + direction;
399
+ direction > 0 ? i < images.length : i >= 0;
400
+ i += direction
401
+ ) {
402
+ const img = images[i];
403
+ if (img.src || img.isRealMockup) {
404
+ return i;
405
+ }
406
+ }
407
+ return -1;
408
+ };
409
+
410
+ const nextIndex = findNextAvailableIndex(currentIndex, 1);
411
+ const prevIndex = findNextAvailableIndex(currentIndex, -1);
412
+
413
+ return (
414
+ <>
415
+ <button
416
+ onClick={() => {
417
+ if (prevIndex !== -1) {
418
+ onIndexChange?.(prevIndex);
419
+ } else {
420
+ }
421
+ }}
422
+ disabled={prevIndex === -1}
423
+ className={`w-10 h-10 rounded-full shadow-lg flex items-center justify-center transition-all duration-200 ${
424
+ prevIndex === -1
425
+ ? "bg-white/40 text-black opacity-50 cursor-not-allowed"
426
+ : "bg-white/90 hover:bg-white text-black"
427
+ }`}
428
+ style={{ touchAction: "manipulation" }}
429
+ aria-label="Previous image"
430
+ >
431
+ <svg
432
+ className="w-4 h-4"
433
+ fill="none"
434
+ stroke="currentColor"
435
+ viewBox="0 0 24 24"
436
+ aria-hidden="true"
437
+ >
438
+ <path
439
+ strokeLinecap="round"
440
+ strokeLinejoin="round"
441
+ strokeWidth={2}
442
+ d="M15 19l-7-7 7-7"
443
+ />
444
+ </svg>
445
+ </button>
446
+ <button
447
+ onClick={() => {
448
+ if (nextIndex !== -1) {
449
+ onIndexChange?.(nextIndex);
450
+ } else {
451
+ }
452
+ }}
453
+ disabled={nextIndex === -1}
454
+ className={`w-10 h-10 rounded-full shadow-lg flex items-center justify-center transition-all duration-200 ${
455
+ nextIndex === -1
456
+ ? "bg-white/40 text-black opacity-50 cursor-not-allowed"
457
+ : "bg-white/90 hover:bg-white text-black"
458
+ }`}
459
+ style={{ touchAction: "manipulation" }}
460
+ aria-label="Next image"
461
+ >
462
+ <svg
463
+ className="w-4 h-4"
464
+ fill="none"
465
+ stroke="currentColor"
466
+ viewBox="0 0 24 24"
467
+ aria-hidden="true"
468
+ >
469
+ <path
470
+ strokeLinecap="round"
471
+ strokeLinejoin="round"
472
+ strokeWidth={2}
473
+ d="M9 5l7 7-7 7"
474
+ />
475
+ </svg>
476
+ </button>
477
+ </>
478
+ );
479
+ })()}
480
+ </div>
481
+ {/* Bottom-left counter */}
482
+ <div style={{ position: "fixed", bottom: 20, left: 24, zIndex: 10, marginBottom: "env(safe-area-inset-bottom, 0px)" }} className="h-10 px-3 rounded-full bg-white/90 text-black text-sm font-medium flex items-center justify-center shadow-lg">
483
+ {(() => {
484
+ const total = Math.max(images.length, 1);
485
+ const current = propCurrentIndex !== undefined
486
+ ? Math.min(propCurrentIndex + 1, total)
487
+ : 1;
488
+ return `${current} / ${total}`;
489
+ })()}
490
+ </div>
491
+ </div>
492
+ </div>
493
+ )}
494
+ </TransformWrapper>
495
+
496
+ {/* Background overlay - click to close */}
497
+ <div
498
+ className="absolute inset-0 bg-black"
499
+ onClick={(e) => { e.stopPropagation(); onClose(); }}
500
+ style={{ zIndex: -1 }}
501
+ />
502
+ </div>,
503
+ document.body
504
+ );
505
+ };
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from 'react';
4
+ import { ZoomOverlay } from './ZoomOverlay';
5
+ import { EnhancedImageViewer } from './EnhancedImageViewer';
6
+ import type { ZoomImage } from './types';
7
+
8
+ interface ResponsiveZoomProps {
9
+ imageIndex: number;
10
+ children: React.ReactNode;
11
+ className?: string;
12
+ style?: React.CSSProperties;
13
+ images?: ZoomImage[];
14
+ imageUrl?: string;
15
+ alt?: string;
16
+ }
17
+
18
+ /**
19
+ * ResponsiveZoom - Adaptive zoom component that provides the best experience for each device type
20
+ *
21
+ * - Desktop (non-touch): Inline 2x zoom with custom cursor (ZoomOverlay)
22
+ * - Touch devices (phones & tablets): Full-screen pinch-to-zoom viewer (EnhancedImageViewer)
23
+ *
24
+ * @example
25
+ * <ResponsiveZoom imageIndex={0}>
26
+ * <ProductImage className="w-full" />
27
+ * </ResponsiveZoom>
28
+ */
29
+ export function ResponsiveZoom({
30
+ imageIndex,
31
+ children,
32
+ className,
33
+ style,
34
+ images = [],
35
+ imageUrl,
36
+ alt = "Product image",
37
+ }: ResponsiveZoomProps) {
38
+ const [isTouchDevice, setIsTouchDevice] = useState(false);
39
+ const [showEnhancedViewer, setShowEnhancedViewer] = useState(false);
40
+ const [viewerIndex, setViewerIndex] = useState(0);
41
+ const [currentImageUrl, setCurrentImageUrl] = useState(imageUrl || '');
42
+
43
+ // Device detection
44
+ useEffect(() => {
45
+ const checkDeviceType = () => {
46
+ const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
47
+ setIsTouchDevice(hasTouch);
48
+ };
49
+
50
+ checkDeviceType();
51
+ window.addEventListener("resize", checkDeviceType);
52
+ return () => window.removeEventListener("resize", checkDeviceType);
53
+ }, []);
54
+
55
+ const handleImageClick = (e: React.MouseEvent<HTMLDivElement>) => {
56
+ // On touch devices, open enhanced viewer
57
+ if (isTouchDevice) {
58
+ // Try to get the image from the click target first
59
+ let imgElement = e.currentTarget.querySelector('img') as HTMLImageElement;
60
+
61
+ // Fallback: try to find by alt text
62
+ if (!imgElement || !imgElement.src) {
63
+ imgElement = document.querySelector(`img[alt*="${alt}"]`) as HTMLImageElement;
64
+ }
65
+
66
+ // Fallback: get any img within the clicked element
67
+ if (!imgElement || !imgElement.src) {
68
+ imgElement = e.currentTarget.querySelector('img') as HTMLImageElement;
69
+ }
70
+
71
+ if (imgElement?.src) {
72
+ setCurrentImageUrl(imgElement.src);
73
+ setViewerIndex(imageIndex);
74
+ setShowEnhancedViewer(true);
75
+ } else {
76
+ console.warn('[ResponsiveZoom] Could not find image element with src');
77
+ }
78
+ }
79
+ };
80
+
81
+ return (
82
+ <>
83
+ {/* For touch devices: clickable wrapper that opens enhanced viewer */}
84
+ {isTouchDevice ? (
85
+ <div
86
+ className={className}
87
+ style={{ ...style, cursor: 'pointer' }}
88
+ onClick={handleImageClick}
89
+ role="button"
90
+ aria-label="View image in full screen"
91
+ tabIndex={0}
92
+ onKeyDown={(e) => {
93
+ if (e.key === 'Enter' || e.key === ' ') {
94
+ e.preventDefault();
95
+ handleImageClick(e as any);
96
+ }
97
+ }}
98
+ >
99
+ {children}
100
+ </div>
101
+ ) : (
102
+ <ZoomOverlay
103
+ imageIndex={imageIndex}
104
+ isTouchDevice={false}
105
+ isLargeTouchDevice={false}
106
+ className={className}
107
+ style={style}
108
+ >
109
+ {children}
110
+ </ZoomOverlay>
111
+ )}
112
+
113
+ {/* Show enhanced viewer on ALL touch devices when clicked */}
114
+ {showEnhancedViewer && isTouchDevice && currentImageUrl && (
115
+ <EnhancedImageViewer
116
+ imageUrl={currentImageUrl}
117
+ alt={alt}
118
+ images={images.length > 0 ? images : [{ src: currentImageUrl, alt }]}
119
+ currentIndex={viewerIndex}
120
+ onClose={() => setShowEnhancedViewer(false)}
121
+ onIndexChange={(index) => {
122
+ setViewerIndex(index);
123
+ if (images.length > 0) {
124
+ const img = images[index];
125
+ if (img?.src) {
126
+ setCurrentImageUrl(img.src);
127
+ }
128
+ }
129
+ }}
130
+ />
131
+ )}
132
+ </>
133
+ );
134
+ }