@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,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
|
+
}
|