@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.
- package/CHANGELOG.md +32 -0
- package/README.md +18 -4
- package/dist/index.cjs +5 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- 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 +1079 -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,1079 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
useEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
memo,
|
|
9
|
+
useCallback,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
useProductOptional,
|
|
13
|
+
useDesignOptional,
|
|
14
|
+
type PlacementDesign,
|
|
15
|
+
} from "../patterns/Product";
|
|
16
|
+
import { useShopOptional } from "../patterns/ShopProvider";
|
|
17
|
+
import { useRealtimeOptional } from "../patterns/RealtimeProvider";
|
|
18
|
+
import { useMockupPriorityOptional } from "../patterns/MockupPriorityProvider";
|
|
19
|
+
import {
|
|
20
|
+
createDesignForPlacements,
|
|
21
|
+
type DesignElement,
|
|
22
|
+
getMockupUrl,
|
|
23
|
+
type Design,
|
|
24
|
+
resolveVariantId,
|
|
25
|
+
resolveMockupId,
|
|
26
|
+
} from "@snowcone-app/sdk";
|
|
27
|
+
|
|
28
|
+
/** Slugify a placement label into the catalog's `placements[].key` form. */
|
|
29
|
+
function slugifyPlacementKey(label: string): string {
|
|
30
|
+
return label
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
33
|
+
.replace(/^_+|_+$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type PlacementLike = { label: string; key?: string; type?: string | null };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert the SDK's `DesignElement[]` (label-keyed, used by the realtime/canvas
|
|
40
|
+
* path) into the public resolver's `Design` (placement-key → fill), so a static
|
|
41
|
+
* `<img>` can be built via the **signing resolver** (`getMockupUrl`) rather than
|
|
42
|
+
* the unsigned direct-renderer builder. The renderer requires a `seal`, which
|
|
43
|
+
* only the resolver adds (ADR-0076).
|
|
44
|
+
*/
|
|
45
|
+
function toPublicDesign(
|
|
46
|
+
elements: DesignElement[],
|
|
47
|
+
placements: PlacementLike[]
|
|
48
|
+
): Design {
|
|
49
|
+
const design: Design = {};
|
|
50
|
+
for (const el of elements) {
|
|
51
|
+
if (!el.placement) continue;
|
|
52
|
+
const match = placements.find((p) => p.label === el.placement);
|
|
53
|
+
const key = match?.key || slugifyPlacementKey(el.placement);
|
|
54
|
+
if (el.type === "color") {
|
|
55
|
+
if (el.hex) design[key] = { color: el.hex };
|
|
56
|
+
} else if (el.imageUrl) {
|
|
57
|
+
const aligned = el.alignment && el.alignment !== "center";
|
|
58
|
+
design[key] =
|
|
59
|
+
aligned || el.tiles
|
|
60
|
+
? {
|
|
61
|
+
src: el.imageUrl,
|
|
62
|
+
...(aligned ? { align: el.alignment } : {}),
|
|
63
|
+
...(el.tiles ? { tile: el.tiles } : {}),
|
|
64
|
+
}
|
|
65
|
+
: el.imageUrl;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return design;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a static mockup `<img>` URL through the signing resolver (ADR-0076).
|
|
73
|
+
* Returns null when the shop isn't configured or the design is empty. `shop` +
|
|
74
|
+
* the local resolver host are published on `window.snowcone` by ShopProvider
|
|
75
|
+
* (prod falls back to img.snowcone.app).
|
|
76
|
+
*/
|
|
77
|
+
function buildHeroMockupUrl(args: {
|
|
78
|
+
productCode: string;
|
|
79
|
+
design: DesignElement[];
|
|
80
|
+
placements: PlacementLike[];
|
|
81
|
+
mockupId: string;
|
|
82
|
+
variantId: string;
|
|
83
|
+
width: number;
|
|
84
|
+
}): string | null {
|
|
85
|
+
const snow =
|
|
86
|
+
(typeof window !== "undefined" &&
|
|
87
|
+
(window as unknown as { snowcone?: { shop?: string; resolver?: string } })
|
|
88
|
+
.snowcone) ||
|
|
89
|
+
{};
|
|
90
|
+
if (!snow.shop) return null;
|
|
91
|
+
const publicDesign = toPublicDesign(args.design, args.placements);
|
|
92
|
+
if (Object.keys(publicDesign).length === 0) return null;
|
|
93
|
+
return getMockupUrl(args.productCode, {
|
|
94
|
+
shop: snow.shop,
|
|
95
|
+
design: publicDesign,
|
|
96
|
+
width: args.width,
|
|
97
|
+
view: args.mockupId,
|
|
98
|
+
variant: args.variantId,
|
|
99
|
+
...(snow.resolver ? { base: snow.resolver } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
import { useImageTransition } from "../hooks/useImageTransition";
|
|
103
|
+
import { useContainerWidth } from "../hooks/viewport/useContainerWidth";
|
|
104
|
+
import { observe } from "../hooks/visibility/observerPool";
|
|
105
|
+
|
|
106
|
+
const EMPTY_IMAGES: DesignElement[] = [];
|
|
107
|
+
|
|
108
|
+
export interface HeroProductImageProps {
|
|
109
|
+
productId?: string;
|
|
110
|
+
mockupId?: string;
|
|
111
|
+
variantId?: string;
|
|
112
|
+
gvid?: string;
|
|
113
|
+
images?: DesignElement[];
|
|
114
|
+
width?: number;
|
|
115
|
+
effects?: { grain?: 1 | 2 };
|
|
116
|
+
endpoint?: string;
|
|
117
|
+
className?: string;
|
|
118
|
+
style?: React.CSSProperties;
|
|
119
|
+
draggable?: boolean;
|
|
120
|
+
placement?: string;
|
|
121
|
+
|
|
122
|
+
// Optional explicit props (takes priority over context)
|
|
123
|
+
artwork?:
|
|
124
|
+
| { type: "regular"; src: string }
|
|
125
|
+
| { type: "pattern"; src: string; tileCount?: number };
|
|
126
|
+
product?: { id: string; name?: string; placements?: any[] };
|
|
127
|
+
|
|
128
|
+
// Event handlers for compatibility with img elements
|
|
129
|
+
onClick?: (e: React.MouseEvent<HTMLImageElement>) => void;
|
|
130
|
+
onLoad?: () => void;
|
|
131
|
+
onError?: () => void;
|
|
132
|
+
onUrlGenerated?: (url: string) => void;
|
|
133
|
+
|
|
134
|
+
// Priority system
|
|
135
|
+
initialPriority?: 1 | 2 | 3;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Controls how the component sources mockup images:
|
|
139
|
+
* - `static`: Only use static mockup URLs (no realtime subscription)
|
|
140
|
+
* - `realtime`: Only use realtime system (shimmer during pending exports, no static fallback)
|
|
141
|
+
* - `auto` (default): Start with static, prefer realtime when available
|
|
142
|
+
*/
|
|
143
|
+
mode?: "static" | "realtime" | "auto";
|
|
144
|
+
|
|
145
|
+
// Realtime URL passed directly from parent (bypasses internal cache lookup)
|
|
146
|
+
// Use this when parent already has the URL from getMockupUrls()
|
|
147
|
+
realtimeUrl?: string;
|
|
148
|
+
|
|
149
|
+
// Transition timing (all in ms)
|
|
150
|
+
fadeDuration?: number;
|
|
151
|
+
shimmerDelay?: number;
|
|
152
|
+
shimmerFadeDuration?: number;
|
|
153
|
+
darkShimmerFadeInDuration?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hero-compatible version of ProductImage that renders as a simple img element
|
|
158
|
+
* for better compatibility with hero image layouts and zoom functionality.
|
|
159
|
+
*
|
|
160
|
+
* Supports realtime mockup updates when used inside RealtimeProvider.
|
|
161
|
+
* The RealtimeProvider coordinates WebSocket connections and canvas exports,
|
|
162
|
+
* while this component simply displays the appropriate mockup for its placement.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* // With realtime updates:
|
|
166
|
+
* <RealtimeProvider productId="abc" placements={[...]}>
|
|
167
|
+
* <HeroProductImage placement="Front" mockupId="..." />
|
|
168
|
+
* </RealtimeProvider>
|
|
169
|
+
*
|
|
170
|
+
* // Without realtime (static mockups):
|
|
171
|
+
* <HeroProductImage placement="Front" mockupId="..." artwork={artwork} />
|
|
172
|
+
*/
|
|
173
|
+
export const HeroProductImage = memo(function HeroProductImage({
|
|
174
|
+
productId,
|
|
175
|
+
mockupId,
|
|
176
|
+
variantId,
|
|
177
|
+
gvid,
|
|
178
|
+
images = EMPTY_IMAGES,
|
|
179
|
+
width: widthProp,
|
|
180
|
+
effects,
|
|
181
|
+
endpoint,
|
|
182
|
+
className,
|
|
183
|
+
style,
|
|
184
|
+
draggable = false,
|
|
185
|
+
placement,
|
|
186
|
+
artwork,
|
|
187
|
+
product,
|
|
188
|
+
onClick,
|
|
189
|
+
onLoad,
|
|
190
|
+
onError,
|
|
191
|
+
onUrlGenerated,
|
|
192
|
+
initialPriority,
|
|
193
|
+
mode = "auto",
|
|
194
|
+
realtimeUrl,
|
|
195
|
+
fadeDuration = 300, // Quick crossfade
|
|
196
|
+
shimmerDelay = 200,
|
|
197
|
+
shimmerFadeDuration = 500,
|
|
198
|
+
darkShimmerFadeInDuration = 300,
|
|
199
|
+
}: HeroProductImageProps) {
|
|
200
|
+
// Context
|
|
201
|
+
const context = useProductOptional();
|
|
202
|
+
const designContext = useDesignOptional();
|
|
203
|
+
const shopContext = useShopOptional();
|
|
204
|
+
const realtimeContext = useRealtimeOptional();
|
|
205
|
+
const priorityContext = useMockupPriorityOptional();
|
|
206
|
+
const priorityContextRef = useRef(priorityContext);
|
|
207
|
+
priorityContextRef.current = priorityContext;
|
|
208
|
+
|
|
209
|
+
// Keep context in a ref for polling access (avoids stale closures)
|
|
210
|
+
const contextRef = useRef(context);
|
|
211
|
+
contextRef.current = context;
|
|
212
|
+
|
|
213
|
+
// Get product ID from explicit prop or context - MOVED UP for initial URL computation
|
|
214
|
+
const actualProductId = product?.id || productId || context?.product?.id;
|
|
215
|
+
|
|
216
|
+
// Resolve variant and mockup IDs - MOVED UP for initial URL computation
|
|
217
|
+
const actualGvid = useMemo(() => {
|
|
218
|
+
return resolveVariantId(context, gvid, variantId);
|
|
219
|
+
}, [gvid, variantId, context?.selection, context?.combinations]);
|
|
220
|
+
|
|
221
|
+
const actualMockupId = useMemo(() => {
|
|
222
|
+
return resolveMockupId(context, actualGvid, mockupId);
|
|
223
|
+
}, [mockupId, actualGvid, context]);
|
|
224
|
+
|
|
225
|
+
// Extract artwork source for initial URL computation
|
|
226
|
+
const artworkSrc = artwork?.src;
|
|
227
|
+
const selectedArtworkSrc = designContext?.selectedArtwork?.src;
|
|
228
|
+
const effectiveArtworkSrc = artworkSrc || selectedArtworkSrc;
|
|
229
|
+
|
|
230
|
+
// Container ref + responsive width (must be before initialStaticUrl which uses width)
|
|
231
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
232
|
+
const measuredWidth = useContainerWidth(containerRef);
|
|
233
|
+
const width = widthProp ?? measuredWidth;
|
|
234
|
+
|
|
235
|
+
// SAFARI FIX: Compute initial static URL during render (not in useEffect)
|
|
236
|
+
// Safari doesn't properly decode images added after initial render,
|
|
237
|
+
// causing a flash when they become visible. By computing the URL during render
|
|
238
|
+
// and passing it as initialUrl, the img element exists from the first render.
|
|
239
|
+
const initialStaticUrl = useMemo(() => {
|
|
240
|
+
// Skip if we have a realtimeUrl (that takes priority)
|
|
241
|
+
if (realtimeUrl) return null;
|
|
242
|
+
// Skip in realtime mode
|
|
243
|
+
if (mode === "realtime") return null;
|
|
244
|
+
// L3 shops: a signer is installed (window.snowcone.signMockupUrl), so the
|
|
245
|
+
// static URL must be signed asynchronously (see the generation effect below).
|
|
246
|
+
// Skip the synchronous unsigned first-paint to avoid a 403 flash.
|
|
247
|
+
if (
|
|
248
|
+
typeof window !== "undefined" &&
|
|
249
|
+
(window as unknown as { snowcone?: { signMockupUrl?: unknown } }).snowcone
|
|
250
|
+
?.signMockupUrl
|
|
251
|
+
) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
// Skip if context is still loading
|
|
255
|
+
if (context?.loading) return null;
|
|
256
|
+
// Skip if no valid artwork
|
|
257
|
+
if (!effectiveArtworkSrc || effectiveArtworkSrc.trim() === "") return null;
|
|
258
|
+
// Skip if missing required IDs or width not yet measured
|
|
259
|
+
if (!actualProductId || !actualMockupId || !actualGvid || actualGvid === "default" || !width) return null;
|
|
260
|
+
|
|
261
|
+
// Get placements for design creation
|
|
262
|
+
const allPlacements = product?.placements || context?.product?.placements || [];
|
|
263
|
+
if (allPlacements.length === 0) return null;
|
|
264
|
+
|
|
265
|
+
// Build placementDesigns with alignment info from design context
|
|
266
|
+
// This ensures alignment changes trigger URL regeneration
|
|
267
|
+
const placementDesigns: Record<string, PlacementDesign> = {};
|
|
268
|
+
if (designContext?.getPlacementDesign && allPlacements) {
|
|
269
|
+
for (const p of allPlacements) {
|
|
270
|
+
// Skip color placements
|
|
271
|
+
if (p.type === "color") continue;
|
|
272
|
+
const design = designContext.getPlacementDesign(p.label);
|
|
273
|
+
placementDesigns[p.label] = {
|
|
274
|
+
imageUrl: effectiveArtworkSrc,
|
|
275
|
+
alignment: design?.alignment || "center",
|
|
276
|
+
} as PlacementDesign;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Create design for the URL
|
|
281
|
+
const design = createDesignForPlacements(allPlacements, images, {
|
|
282
|
+
artworkSrc: effectiveArtworkSrc,
|
|
283
|
+
placementDesigns,
|
|
284
|
+
});
|
|
285
|
+
if (!design || design.length === 0) return null;
|
|
286
|
+
|
|
287
|
+
// Build the static URL through the SIGNING RESOLVER (img), not the unsigned
|
|
288
|
+
// direct-renderer builder: the renderer now mandates a `seal` that only the
|
|
289
|
+
// resolver adds (ADR-0076).
|
|
290
|
+
return buildHeroMockupUrl({
|
|
291
|
+
productCode: actualProductId,
|
|
292
|
+
design,
|
|
293
|
+
placements: allPlacements,
|
|
294
|
+
mockupId: actualMockupId,
|
|
295
|
+
variantId: actualGvid,
|
|
296
|
+
width,
|
|
297
|
+
});
|
|
298
|
+
}, [
|
|
299
|
+
realtimeUrl,
|
|
300
|
+
mode,
|
|
301
|
+
context?.loading,
|
|
302
|
+
effectiveArtworkSrc,
|
|
303
|
+
actualProductId,
|
|
304
|
+
actualMockupId,
|
|
305
|
+
actualGvid,
|
|
306
|
+
product?.placements,
|
|
307
|
+
context?.product?.placements,
|
|
308
|
+
images,
|
|
309
|
+
width,
|
|
310
|
+
effects,
|
|
311
|
+
// Include placements to trigger recomputation when alignment changes
|
|
312
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
313
|
+
JSON.stringify(designContext?.placements),
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
// Use the state machine hook for image transitions
|
|
317
|
+
// SAFARI FIX: Pass initialStaticUrl so the img element exists from the first render
|
|
318
|
+
// This prevents the flash caused by Safari not decoding images added after initial render
|
|
319
|
+
const {
|
|
320
|
+
layers,
|
|
321
|
+
setTargetUrl,
|
|
322
|
+
addLayerDirectly,
|
|
323
|
+
onImageLoad,
|
|
324
|
+
onLayerTransitionEnd,
|
|
325
|
+
durations,
|
|
326
|
+
showShimmer,
|
|
327
|
+
shimmerOpacity,
|
|
328
|
+
} = useImageTransition({
|
|
329
|
+
fadeDuration,
|
|
330
|
+
shimmerDelay,
|
|
331
|
+
initialUrl: realtimeUrl || initialStaticUrl,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Error state
|
|
335
|
+
const [error, setError] = useState<string | null>(null);
|
|
336
|
+
|
|
337
|
+
// SAFARI FIX: Disable visibility-based loading - always compute URLs immediately.
|
|
338
|
+
// Safari handles lazy loading natively, and state changes when images scroll into view
|
|
339
|
+
// can cause a flash. Let Safari manage everything by computing all URLs on mount.
|
|
340
|
+
const [isInPreloadZone, setIsInPreloadZone] = useState(true);
|
|
341
|
+
|
|
342
|
+
// Refs
|
|
343
|
+
const componentIdRef = useRef<string>(
|
|
344
|
+
`hero-product-image-${Math.random().toString(36).substr(2, 9)}`
|
|
345
|
+
);
|
|
346
|
+
const [containerMounted, setContainerMounted] = useState(false);
|
|
347
|
+
const setContainerRef = useCallback((node: HTMLDivElement | null) => {
|
|
348
|
+
containerRef.current = node;
|
|
349
|
+
setContainerMounted(!!node);
|
|
350
|
+
}, []);
|
|
351
|
+
// Initialize lastUrlRef with computed initial URL, so deduplication works
|
|
352
|
+
const lastUrlRef = useRef<string | null>(realtimeUrl ?? initialStaticUrl ?? null);
|
|
353
|
+
const priorityRegisteredRef = useRef(false);
|
|
354
|
+
// Track if external onLoad has been called (only call once per load)
|
|
355
|
+
const onLoadCalledRef = useRef(false);
|
|
356
|
+
// Track previous artwork src to detect changes and clear cache
|
|
357
|
+
const prevArtworkSrcRef = useRef<string | undefined>(undefined);
|
|
358
|
+
// Track which artwork the current realtimeUrl is for (to detect stale realtime URLs)
|
|
359
|
+
const realtimeArtworkSrcRef = useRef<string | undefined>(undefined);
|
|
360
|
+
// Track whether we've received a realtime result (for auto mode to switch from static to realtime)
|
|
361
|
+
const hasReceivedRealtimeRef = useRef(false);
|
|
362
|
+
// Track whether there's a pending export (to skip static URL generation during realtime updates)
|
|
363
|
+
const isPendingExportRef = useRef(false);
|
|
364
|
+
// Ref for onUrlGenerated callback (used in realtime subscription without adding to deps)
|
|
365
|
+
const onUrlGeneratedRef = useRef(onUrlGenerated);
|
|
366
|
+
onUrlGeneratedRef.current = onUrlGenerated;
|
|
367
|
+
|
|
368
|
+
// Track layers.length in a ref so we can read it without depending on it
|
|
369
|
+
// This prevents effect re-runs when layers change (which would cause duplicate requests)
|
|
370
|
+
const layersLengthRef = useRef(layers.length);
|
|
371
|
+
layersLengthRef.current = layers.length;
|
|
372
|
+
|
|
373
|
+
// Visibility-based preloading: observe when container enters preload zone (1000px margin)
|
|
374
|
+
// Priority 1 mockups are already in preload zone, others wait until they approach viewport
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
// Priority 1 already set isInPreloadZone=true on init
|
|
377
|
+
if (initialPriority === 1) return;
|
|
378
|
+
// Need a mounted container to observe
|
|
379
|
+
if (!containerMounted || !containerRef.current) return;
|
|
380
|
+
// Already in preload zone, no need to observe
|
|
381
|
+
if (isInPreloadZone) return;
|
|
382
|
+
|
|
383
|
+
// SSR check
|
|
384
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
385
|
+
setIsInPreloadZone(true);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const unobserve = observe(
|
|
390
|
+
containerRef.current,
|
|
391
|
+
(entry) => {
|
|
392
|
+
if (entry.isIntersecting) {
|
|
393
|
+
setIsInPreloadZone(true);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
{ rootMargin: "1000px 0px", threshold: 0 }
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
return unobserve;
|
|
400
|
+
}, [containerMounted, initialPriority, isInPreloadZone]);
|
|
401
|
+
|
|
402
|
+
// Handle realtimeUrl prop passed directly from parent
|
|
403
|
+
// This bypasses cache lookup and is more reliable for carousel decode window scenarios
|
|
404
|
+
// Uses addLayerDirectly so actual <img> element loads with loading="eager" fetchPriority="high"
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
if (!realtimeUrl) return;
|
|
407
|
+
if (realtimeUrl === lastUrlRef.current) return;
|
|
408
|
+
|
|
409
|
+
lastUrlRef.current = realtimeUrl;
|
|
410
|
+
// Track which artwork this realtimeUrl is for (used to detect stale realtime URLs)
|
|
411
|
+
realtimeArtworkSrcRef.current = artworkSrc || selectedArtworkSrc;
|
|
412
|
+
// addLayerDirectly handles shimmer timing internally (500ms delay)
|
|
413
|
+
// SAFARI FIX: Skip transition for initial load to prevent flash
|
|
414
|
+
const isInitialLoad = layersLengthRef.current === 0;
|
|
415
|
+
addLayerDirectly(realtimeUrl, { skipTransition: isInitialLoad });
|
|
416
|
+
}, [realtimeUrl, addLayerDirectly, actualMockupId, placement]);
|
|
417
|
+
|
|
418
|
+
// Watch for realtime mockup results from centralized cache
|
|
419
|
+
// The provider handles cache-busting, so we just need to react to changes
|
|
420
|
+
// Note: If realtimeUrl prop is provided, that takes priority
|
|
421
|
+
// Uses addLayerDirectly so actual <img> element loads with loading="eager" fetchPriority="high"
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
// Only subscribe in realtime or auto modes
|
|
424
|
+
if (mode === "static") {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Skip if realtimeUrl prop is provided - parent is handling URL management
|
|
428
|
+
if (realtimeUrl) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!actualMockupId) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Always check for cached URL, even when not configured (e.g., after exiting editor)
|
|
436
|
+
// This preserves the last realtime mockup instead of falling back to static
|
|
437
|
+
const existingUrl = realtimeContext?.getMockupUrl?.(actualMockupId);
|
|
438
|
+
if (existingUrl && existingUrl !== lastUrlRef.current) {
|
|
439
|
+
hasReceivedRealtimeRef.current = true;
|
|
440
|
+
lastUrlRef.current = existingUrl;
|
|
441
|
+
// addLayerDirectly handles shimmer timing internally (500ms delay)
|
|
442
|
+
// SAFARI FIX: Skip transition for initial load to prevent flash
|
|
443
|
+
const isInitialLoad = layersLengthRef.current === 0;
|
|
444
|
+
addLayerDirectly(existingUrl, { skipTransition: isInitialLoad });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Subscribe for updates - no need to wait for isConfigured since subscription
|
|
448
|
+
// is safe to set up early (it will just receive results once connected)
|
|
449
|
+
// The mobile carousel also subscribes without checking isConfigured
|
|
450
|
+
const subscribeFn = realtimeContext?.subscribeMockupResultById;
|
|
451
|
+
if (!subscribeFn) return;
|
|
452
|
+
|
|
453
|
+
const handleMockupResult = (result: {
|
|
454
|
+
mockupId: string;
|
|
455
|
+
imageUrl: string;
|
|
456
|
+
}) => {
|
|
457
|
+
if (!result?.imageUrl) return;
|
|
458
|
+
|
|
459
|
+
// Mark that we've received a realtime result (for auto mode)
|
|
460
|
+
hasReceivedRealtimeRef.current = true;
|
|
461
|
+
// Clear pending flag - realtime result has arrived
|
|
462
|
+
isPendingExportRef.current = false;
|
|
463
|
+
|
|
464
|
+
// Get the cache-busted URL from the centralized cache
|
|
465
|
+
// The provider already adds cache-busting, so we use getMockupUrl()
|
|
466
|
+
const cachedUrl = realtimeContext.getMockupUrl(result.mockupId);
|
|
467
|
+
if (!cachedUrl) return;
|
|
468
|
+
|
|
469
|
+
// Skip if this is the same URL we already have
|
|
470
|
+
if (cachedUrl === lastUrlRef.current) return;
|
|
471
|
+
lastUrlRef.current = cachedUrl;
|
|
472
|
+
|
|
473
|
+
// addLayerDirectly handles shimmer timing internally (500ms delay)
|
|
474
|
+
// SAFARI FIX: Skip transition for initial load to prevent flash
|
|
475
|
+
const isInitialLoad = layersLengthRef.current === 0;
|
|
476
|
+
addLayerDirectly(cachedUrl, { skipTransition: isInitialLoad });
|
|
477
|
+
// Notify parent of the new realtime URL (for ImageEdgeBlur, etc.)
|
|
478
|
+
onUrlGeneratedRef.current?.(cachedUrl);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const unsubscribe = subscribeFn(actualMockupId, handleMockupResult);
|
|
482
|
+
return () => {
|
|
483
|
+
unsubscribe?.();
|
|
484
|
+
};
|
|
485
|
+
}, [
|
|
486
|
+
mode,
|
|
487
|
+
realtimeUrl,
|
|
488
|
+
realtimeContext?.subscribeMockupResultById,
|
|
489
|
+
realtimeContext?.getMockupUrl,
|
|
490
|
+
actualMockupId,
|
|
491
|
+
addLayerDirectly,
|
|
492
|
+
// NOTE: layers.length removed - use layersLengthRef instead to prevent duplicate requests
|
|
493
|
+
]);
|
|
494
|
+
|
|
495
|
+
// Subscribe to pending export notifications
|
|
496
|
+
// Marks pending to prevent static URL effect from adding a layer while export is in progress
|
|
497
|
+
// Shimmer is handled by addLayerDirectly with a 500ms delay (skipped for fast loads)
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
// Only subscribe in realtime or auto modes
|
|
500
|
+
if (mode === "static") return;
|
|
501
|
+
if (!realtimeContext?.subscribePendingBlob || !placement) return;
|
|
502
|
+
|
|
503
|
+
const handlePendingExport = (pendingPlacement: string) => {
|
|
504
|
+
// Only mark pending if this is for our placement
|
|
505
|
+
if (pendingPlacement === placement) {
|
|
506
|
+
// Mark as pending to prevent static URL effect from adding a layer
|
|
507
|
+
isPendingExportRef.current = true;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const unsubscribe = realtimeContext.subscribePendingBlob(handlePendingExport);
|
|
512
|
+
return () => {
|
|
513
|
+
unsubscribe?.();
|
|
514
|
+
};
|
|
515
|
+
}, [mode, realtimeContext?.subscribePendingBlob, placement]);
|
|
516
|
+
|
|
517
|
+
// Register with priority context
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
if (!priorityContext || !actualMockupId || !placement) return;
|
|
520
|
+
|
|
521
|
+
priorityContext.registerMockup(actualMockupId, placement, initialPriority);
|
|
522
|
+
priorityRegisteredRef.current = true;
|
|
523
|
+
|
|
524
|
+
return () => {
|
|
525
|
+
if (priorityRegisteredRef.current) {
|
|
526
|
+
priorityContextRef.current?.unregisterMockup(actualMockupId);
|
|
527
|
+
priorityRegisteredRef.current = false;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}, [priorityContext, actualMockupId, placement, initialPriority]);
|
|
531
|
+
|
|
532
|
+
// Pass element ref to priority context
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
if (!priorityContext?.updateElementRef || !actualMockupId) return;
|
|
535
|
+
|
|
536
|
+
if (containerMounted && containerRef.current) {
|
|
537
|
+
priorityContext.updateElementRef(actualMockupId, containerRef.current);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return () => {
|
|
541
|
+
priorityContextRef.current?.updateElementRef?.(actualMockupId, null);
|
|
542
|
+
};
|
|
543
|
+
}, [priorityContext, containerMounted, actualMockupId]);
|
|
544
|
+
|
|
545
|
+
// Extract only PRIMITIVE values we need from contexts to avoid
|
|
546
|
+
// recreating the callback when object references change.
|
|
547
|
+
// This prevents infinite render loops.
|
|
548
|
+
const getPlacementDesign = designContext?.getPlacementDesign;
|
|
549
|
+
|
|
550
|
+
// Extract primitives from artwork prop (artworkSrc and selectedArtworkSrc defined above for initial URL)
|
|
551
|
+
const artworkType = artwork?.type;
|
|
552
|
+
const artworkTileCount =
|
|
553
|
+
artwork?.type === "pattern" ? artwork?.tileCount : undefined;
|
|
554
|
+
|
|
555
|
+
// Extract primitives from context (selectedArtworkSrc defined above for initial URL)
|
|
556
|
+
const selectedArtworkType = designContext?.selectedArtwork?.type;
|
|
557
|
+
const selectedArtworkTileCount =
|
|
558
|
+
designContext?.selectedArtwork?.type === "pattern"
|
|
559
|
+
? designContext?.selectedArtwork?.tileCount
|
|
560
|
+
: undefined;
|
|
561
|
+
|
|
562
|
+
// Detect artwork changes and reset local URL tracking
|
|
563
|
+
// This ensures we don't skip updates when artwork changes
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
const currentSrc = artworkSrc || selectedArtworkSrc;
|
|
566
|
+
if (currentSrc !== prevArtworkSrcRef.current) {
|
|
567
|
+
// Artwork changed - reset local tracking so next update is applied
|
|
568
|
+
lastUrlRef.current = null;
|
|
569
|
+
prevArtworkSrcRef.current = currentSrc;
|
|
570
|
+
// Reset onLoad tracking so it fires again for new artwork
|
|
571
|
+
onLoadCalledRef.current = false;
|
|
572
|
+
// Reset realtime tracking so static URL generation isn't blocked
|
|
573
|
+
hasReceivedRealtimeRef.current = false;
|
|
574
|
+
// Reset realtime artwork tracking so stale URLs are detected correctly
|
|
575
|
+
realtimeArtworkSrcRef.current = undefined;
|
|
576
|
+
}
|
|
577
|
+
}, [artworkSrc, selectedArtworkSrc]);
|
|
578
|
+
|
|
579
|
+
// Keep refs to objects we need to pass through (these don't need to trigger re-creation)
|
|
580
|
+
const contextSelectionRef = useRef(context?.selection);
|
|
581
|
+
const contextOptionAttributesRef = useRef(context?.optionAttributes);
|
|
582
|
+
contextSelectionRef.current = context?.selection;
|
|
583
|
+
contextOptionAttributesRef.current = context?.optionAttributes;
|
|
584
|
+
|
|
585
|
+
// Wrapper for createDesignForPlacements
|
|
586
|
+
const createDesignForPlacementsWrapper = useCallback(
|
|
587
|
+
(
|
|
588
|
+
placements: any[],
|
|
589
|
+
providedImages?: DesignElement[]
|
|
590
|
+
): DesignElement[] | null => {
|
|
591
|
+
const effectiveArtworkSrc = artworkSrc || selectedArtworkSrc;
|
|
592
|
+
|
|
593
|
+
if (
|
|
594
|
+
!effectiveArtworkSrc ||
|
|
595
|
+
effectiveArtworkSrc.trim() === "" ||
|
|
596
|
+
effectiveArtworkSrc === "undefined" ||
|
|
597
|
+
effectiveArtworkSrc === "null"
|
|
598
|
+
) {
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const placementDesigns: Record<string, PlacementDesign> = {};
|
|
603
|
+
if (getPlacementDesign && placements) {
|
|
604
|
+
for (const p of placements) {
|
|
605
|
+
// Skip color placements - treat null/undefined/missing type as "image" (default)
|
|
606
|
+
if (p.type === "color") continue;
|
|
607
|
+
const design = getPlacementDesign(p.label);
|
|
608
|
+
// Always create a placement design with current artwork
|
|
609
|
+
// Use alignment from context if available, otherwise default to center
|
|
610
|
+
placementDesigns[p.label] = {
|
|
611
|
+
imageUrl: effectiveArtworkSrc,
|
|
612
|
+
alignment: design?.alignment || "center",
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Get tiles from multiple sources (in order of priority):
|
|
618
|
+
// 1. Placement design tiles (set by TileCount slider with placement prop)
|
|
619
|
+
// 2. Explicit artwork prop tileCount
|
|
620
|
+
// 3. Design context selectedArtwork tileCount
|
|
621
|
+
// 4. undefined (no tiles)
|
|
622
|
+
// Note: We check the first placement's design for tiles since patterns apply globally
|
|
623
|
+
// IMPORTANT: Only apply tiles if the current artwork is a pattern!
|
|
624
|
+
// This prevents stale placement tiles from being applied to regular artwork.
|
|
625
|
+
const firstPlacement = placements.find((p: any) => p.type !== "color");
|
|
626
|
+
const placementTiles = firstPlacement
|
|
627
|
+
? getPlacementDesign?.(firstPlacement.label)?.tiles
|
|
628
|
+
: undefined;
|
|
629
|
+
|
|
630
|
+
// Determine if current artwork is a pattern (check both explicit prop and context)
|
|
631
|
+
const isCurrentArtworkPattern =
|
|
632
|
+
artworkType === "pattern" || selectedArtworkType === "pattern";
|
|
633
|
+
|
|
634
|
+
// Only apply tiles if current artwork is a pattern
|
|
635
|
+
const tiles = isCurrentArtworkPattern
|
|
636
|
+
? placementTiles
|
|
637
|
+
? (placementTiles as 0.25 | 0.5 | 1 | 2 | 4)
|
|
638
|
+
: artworkTileCount
|
|
639
|
+
? (artworkTileCount as 0.25 | 0.5 | 1 | 2 | 4)
|
|
640
|
+
: selectedArtworkTileCount
|
|
641
|
+
? (selectedArtworkTileCount as 0.25 | 0.5 | 1 | 2 | 4)
|
|
642
|
+
: undefined
|
|
643
|
+
: undefined;
|
|
644
|
+
|
|
645
|
+
return createDesignForPlacements(placements, providedImages, {
|
|
646
|
+
selection: contextSelectionRef.current,
|
|
647
|
+
attributes: contextOptionAttributesRef.current,
|
|
648
|
+
artworkSrc: effectiveArtworkSrc,
|
|
649
|
+
placementDesigns: placementDesigns,
|
|
650
|
+
tiles: tiles,
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
[
|
|
654
|
+
getPlacementDesign,
|
|
655
|
+
artworkSrc,
|
|
656
|
+
artworkType,
|
|
657
|
+
artworkTileCount,
|
|
658
|
+
selectedArtworkSrc,
|
|
659
|
+
selectedArtworkType,
|
|
660
|
+
selectedArtworkTileCount,
|
|
661
|
+
]
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// Generate static mockup URL
|
|
665
|
+
// This runs even when realtimeUrl is provided - realtimeUrl is for keeping the OLD
|
|
666
|
+
// image visible while we generate and load the NEW image with current artwork
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
// Skip entirely in realtime mode - only use realtime subscription for mockups
|
|
669
|
+
if (mode === "realtime") return;
|
|
670
|
+
|
|
671
|
+
// SAFARI FIX: Skip if we already have initialStaticUrl computed during render.
|
|
672
|
+
// The useImageTransition hook was initialized with this URL, so calling addLayerDirectly
|
|
673
|
+
// would just trigger unnecessary state changes (decode(), markAsLoaded, etc.) that
|
|
674
|
+
// cause re-renders and flash in Safari.
|
|
675
|
+
// Only run this effect for SUBSEQUENT artwork changes, not initial load.
|
|
676
|
+
if (initialStaticUrl && lastUrlRef.current === initialStaticUrl) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// VISIBILITY-BASED LOADING: Skip until component is in preload zone (1000px from viewport)
|
|
681
|
+
// This prevents all mockups from loading at once on page load (Safari optimization)
|
|
682
|
+
// Priority 1 mockups start with isInPreloadZone=true so they load immediately
|
|
683
|
+
if (!isInPreloadZone) return;
|
|
684
|
+
|
|
685
|
+
// Check for valid artwork (use primitive values from outer scope)
|
|
686
|
+
const effectiveArtwork = artworkSrc || selectedArtworkSrc;
|
|
687
|
+
|
|
688
|
+
// Wait for context to load
|
|
689
|
+
if (context?.loading) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const hasValidArtwork =
|
|
693
|
+
effectiveArtwork &&
|
|
694
|
+
effectiveArtwork.trim() !== "" &&
|
|
695
|
+
effectiveArtwork !== "undefined" &&
|
|
696
|
+
effectiveArtwork !== "null";
|
|
697
|
+
|
|
698
|
+
if (!hasValidArtwork) {
|
|
699
|
+
setTargetUrl(null);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Check for required props
|
|
704
|
+
if (
|
|
705
|
+
!actualProductId ||
|
|
706
|
+
!actualMockupId ||
|
|
707
|
+
!actualGvid ||
|
|
708
|
+
actualGvid === "default" ||
|
|
709
|
+
!width
|
|
710
|
+
) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Pre-generate URL
|
|
715
|
+
const allPlacements =
|
|
716
|
+
product?.placements || context?.product?.placements || [];
|
|
717
|
+
const designToUse = createDesignForPlacementsWrapper(allPlacements, images);
|
|
718
|
+
|
|
719
|
+
if (designToUse === null) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const url = buildHeroMockupUrl({
|
|
724
|
+
productCode: actualProductId,
|
|
725
|
+
design: designToUse,
|
|
726
|
+
placements: allPlacements,
|
|
727
|
+
mockupId: actualMockupId,
|
|
728
|
+
variantId: actualGvid,
|
|
729
|
+
width: width,
|
|
730
|
+
});
|
|
731
|
+
if (!url) return;
|
|
732
|
+
|
|
733
|
+
// Skip if we already requested this exact URL
|
|
734
|
+
// This prevents infinite loops where the effect keeps calling setTargetUrl
|
|
735
|
+
// with the same URL, which triggers state updates that re-trigger the effect.
|
|
736
|
+
//
|
|
737
|
+
// IMPORTANT: We do NOT check layers.length here because:
|
|
738
|
+
// 1. lastUrlRef is set BEFORE calling setTargetUrl (line below)
|
|
739
|
+
// 2. So on subsequent effect runs, we'll skip even if the image hasn't loaded yet
|
|
740
|
+
// 3. This prevents the infinite loop that occurred when layers.length === 0
|
|
741
|
+
//
|
|
742
|
+
// For initial load / navigation / StrictMode remount:
|
|
743
|
+
// - lastUrlRef starts as null, so url !== lastUrlRef.current, and we proceed
|
|
744
|
+
if (url === lastUrlRef.current) return;
|
|
745
|
+
|
|
746
|
+
// Update lastUrlRef IMMEDIATELY when we decide to request a new URL
|
|
747
|
+
lastUrlRef.current = url;
|
|
748
|
+
|
|
749
|
+
// If we have a realtimeUrl (old image showing), show shimmer during transition
|
|
750
|
+
// Use addLayerDirectly so the actual <img> element loads with loading="eager" fetchPriority="high"
|
|
751
|
+
// This lets Safari track images in its "Images" memory category
|
|
752
|
+
const hasExistingImage = !!realtimeUrl || layersLengthRef.current > 0;
|
|
753
|
+
setError(null);
|
|
754
|
+
|
|
755
|
+
// Skip adding layer if realtimeUrl is for the CURRENT artwork
|
|
756
|
+
// If artwork changed, the realtimeUrl is stale and we should show the new static URL
|
|
757
|
+
const currentArtworkSrc = artworkSrc || selectedArtworkSrc;
|
|
758
|
+
const isRealtimeUrlFresh = realtimeUrl && realtimeArtworkSrcRef.current === currentArtworkSrc;
|
|
759
|
+
|
|
760
|
+
if (isRealtimeUrlFresh) {
|
|
761
|
+
// Pass the realtimeUrl (not static url) since it's already showing correctly
|
|
762
|
+
onUrlGeneratedRef.current?.(realtimeUrl);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Skip adding static layer if there's a pending export
|
|
767
|
+
// The realtime result will arrive shortly with the correct mockup
|
|
768
|
+
if (isPendingExportRef.current) {
|
|
769
|
+
// Don't call onUrlGenerated during pending - wait for realtime result
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// In auto mode, skip static layer if we've already received realtime results
|
|
774
|
+
// This preserves the realtime mockup when exiting the editor
|
|
775
|
+
if (mode === "auto" && hasReceivedRealtimeRef.current) {
|
|
776
|
+
// Don't call onUrlGenerated - let the realtime URL stay as-is
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
// Use addLayerDirectly - lets the actual <img> handle loading
|
|
780
|
+
// Shimmer timing is handled internally (500ms delay, skipped for fast/cached loads)
|
|
781
|
+
// SAFARI FIX: Skip transition for initial load (when no existing layers) to prevent flash
|
|
782
|
+
// The fade transition is only useful for artwork changes, not initial display
|
|
783
|
+
const isInitialLoad = layersLengthRef.current === 0;
|
|
784
|
+
const render = (finalUrl: string) => {
|
|
785
|
+
// Guard against a newer URL having been requested while we were signing.
|
|
786
|
+
if (lastUrlRef.current !== url) return;
|
|
787
|
+
addLayerDirectly(finalUrl, { skipTransition: isInitialLoad });
|
|
788
|
+
// Use ref to avoid re-running this effect when onUrlGenerated changes
|
|
789
|
+
// This prevents static URL from overwriting realtime URL on parent re-render
|
|
790
|
+
onUrlGeneratedRef.current?.(finalUrl);
|
|
791
|
+
};
|
|
792
|
+
// L3 shops: the host app installs `window.snowcone.signMockupUrl` to append
|
|
793
|
+
// an &signature (the resolver rejects unsigned URLs). Without it (L0), the
|
|
794
|
+
// public URL renders as-is.
|
|
795
|
+
const signer =
|
|
796
|
+
typeof window !== "undefined"
|
|
797
|
+
? (window as unknown as {
|
|
798
|
+
snowcone?: { signMockupUrl?: (u: string) => Promise<string> };
|
|
799
|
+
}).snowcone?.signMockupUrl
|
|
800
|
+
: undefined;
|
|
801
|
+
if (signer) {
|
|
802
|
+
signer(url)
|
|
803
|
+
.then((signed) => render(signed || url))
|
|
804
|
+
.catch(() => render(url));
|
|
805
|
+
} else {
|
|
806
|
+
render(url);
|
|
807
|
+
}
|
|
808
|
+
}, [
|
|
809
|
+
mode,
|
|
810
|
+
isInPreloadZone, // Visibility-based loading: only generate URL when in preload zone
|
|
811
|
+
// NOTE: realtimeContext?.isConfigured intentionally NOT included
|
|
812
|
+
// We don't want to re-run static URL generation when realtime is disabled
|
|
813
|
+
// The artwork change detection effect handles clearing the cache when needed
|
|
814
|
+
actualProductId,
|
|
815
|
+
actualMockupId,
|
|
816
|
+
actualGvid,
|
|
817
|
+
images,
|
|
818
|
+
width,
|
|
819
|
+
effects,
|
|
820
|
+
// Use primitive values extracted at the top of the component
|
|
821
|
+
artworkSrc,
|
|
822
|
+
artworkType,
|
|
823
|
+
artworkTileCount,
|
|
824
|
+
selectedArtworkSrc,
|
|
825
|
+
selectedArtworkType,
|
|
826
|
+
selectedArtworkTileCount,
|
|
827
|
+
// Stringify arrays/objects to create stable string dependencies
|
|
828
|
+
JSON.stringify(product?.placements),
|
|
829
|
+
JSON.stringify(context?.selection),
|
|
830
|
+
JSON.stringify(context?.product?.placements),
|
|
831
|
+
JSON.stringify(designContext?.placements),
|
|
832
|
+
context?.loading,
|
|
833
|
+
addLayerDirectly,
|
|
834
|
+
createDesignForPlacementsWrapper,
|
|
835
|
+
// NOTE: onUrlGenerated intentionally NOT included - use onUrlGeneratedRef instead
|
|
836
|
+
// This prevents the static URL effect from re-running on parent re-renders,
|
|
837
|
+
// which would overwrite the realtime URL that was just set by handleMockupResult
|
|
838
|
+
realtimeUrl,
|
|
839
|
+
// NOTE: Do NOT include layers or preloadUrl as dependencies!
|
|
840
|
+
// They change when addLayerDirectly is called, which would cause infinite loops.
|
|
841
|
+
// The useImageTransition hook handles deduplication internally.
|
|
842
|
+
]);
|
|
843
|
+
|
|
844
|
+
// Check if we have artwork (effectiveArtworkSrc defined at top of component)
|
|
845
|
+
const hasArtwork =
|
|
846
|
+
effectiveArtworkSrc &&
|
|
847
|
+
effectiveArtworkSrc.trim() !== "" &&
|
|
848
|
+
effectiveArtworkSrc !== "undefined" &&
|
|
849
|
+
effectiveArtworkSrc !== "null";
|
|
850
|
+
|
|
851
|
+
// DISPLAY URL PRIORITY:
|
|
852
|
+
// 1. layers (if we have any) - this includes realtime mockup updates
|
|
853
|
+
// 2. realtimeUrl prop (passed from parent)
|
|
854
|
+
// 3. initialStaticUrl (computed at mount)
|
|
855
|
+
//
|
|
856
|
+
// Note: We MUST read from layers for realtime updates to work.
|
|
857
|
+
// The realtime subscription calls addLayerDirectly() which updates layers state.
|
|
858
|
+
const topLayer = layers[layers.length - 1];
|
|
859
|
+
const displayUrl = topLayer?.url || realtimeUrl || initialStaticUrl;
|
|
860
|
+
|
|
861
|
+
// CSS crossfade: two stacked <img> elements. When displayUrl changes,
|
|
862
|
+
// the old image stays visible while the new one fades in on top.
|
|
863
|
+
// No View Transition API — stays in normal stacking context, no z-index issues.
|
|
864
|
+
//
|
|
865
|
+
// IMPORTANT: these hooks (and the crossfade `useEffect` below) MUST stay
|
|
866
|
+
// above the `!hasArtwork` / `error` early-return branches. Otherwise React
|
|
867
|
+
// sees a different hook count per render whenever artwork (un)loads or an
|
|
868
|
+
// error toggles, and throws "change in the order of Hooks called by
|
|
869
|
+
// HeroProductImage". Same rule for any new hook added in this component.
|
|
870
|
+
const CROSSFADE_MS = 400;
|
|
871
|
+
const [prevUrl, setPrevUrl] = useState<string | null>(null);
|
|
872
|
+
const [showNew, setShowNew] = useState(false);
|
|
873
|
+
// `renderedUrl` is what the visible <img> actually paints. We delay updating
|
|
874
|
+
// it from `displayUrl` until the new image is preloaded, so the displayed
|
|
875
|
+
// <img> never flashes the new src before the prevUrl crossfade-from layer
|
|
876
|
+
// is in place. Without this delay, when the browser already has the new
|
|
877
|
+
// image cached, the visible <img> snaps to NEW immediately, then the
|
|
878
|
+
// useEffect runs and overlays prevUrl (showing OLD on top), then prevUrl
|
|
879
|
+
// fades out to reveal NEW again — i.e. the new → old → new flash.
|
|
880
|
+
const [renderedUrl, setRenderedUrl] = useState<string | null>(displayUrl);
|
|
881
|
+
// `renderedUrl` is set as soon as a URL exists, but the <img> paints nothing
|
|
882
|
+
// until the render is actually fetched (seconds, on a cold render). Keep the
|
|
883
|
+
// pulsing skeleton up until the first image has painted.
|
|
884
|
+
const [firstImageLoaded, setFirstImageLoaded] = useState(false);
|
|
885
|
+
const prevDisplayUrlRef = useRef<string | null>(displayUrl);
|
|
886
|
+
// Fire the L3 "you forgot signMockupUrl" dev hint at most once per component.
|
|
887
|
+
const signHintShownRef = useRef(false);
|
|
888
|
+
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (!displayUrl || displayUrl === prevDisplayUrlRef.current) return;
|
|
891
|
+
const oldUrl = prevDisplayUrlRef.current;
|
|
892
|
+
prevDisplayUrlRef.current = displayUrl;
|
|
893
|
+
|
|
894
|
+
// Initial load — no crossfade. Just update rendered URL.
|
|
895
|
+
if (!oldUrl) {
|
|
896
|
+
setRenderedUrl(displayUrl);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Preload new image, then atomically: set prevUrl = old, set rendered = new.
|
|
901
|
+
// Both happen in the same render so the user always sees either OLD or
|
|
902
|
+
// OLD-on-top-of-NEW, never bare NEW before prev is in place.
|
|
903
|
+
const preload = new Image();
|
|
904
|
+
preload.crossOrigin = 'anonymous';
|
|
905
|
+
preload.src = displayUrl;
|
|
906
|
+
const startCrossfade = () => {
|
|
907
|
+
setPrevUrl(oldUrl);
|
|
908
|
+
setRenderedUrl(displayUrl);
|
|
909
|
+
setShowNew(false);
|
|
910
|
+
// Trigger fade-in on next frame so the CSS transition plays
|
|
911
|
+
requestAnimationFrame(() => {
|
|
912
|
+
requestAnimationFrame(() => setShowNew(true));
|
|
913
|
+
});
|
|
914
|
+
};
|
|
915
|
+
if (preload.complete) startCrossfade();
|
|
916
|
+
else preload.onload = startCrossfade;
|
|
917
|
+
|
|
918
|
+
return () => { preload.onload = null; };
|
|
919
|
+
}, [displayUrl]);
|
|
920
|
+
|
|
921
|
+
// Clean up old layer after crossfade completes
|
|
922
|
+
const handleCrossfadeEnd = useCallback(() => {
|
|
923
|
+
setPrevUrl(null);
|
|
924
|
+
setShowNew(false);
|
|
925
|
+
}, []);
|
|
926
|
+
|
|
927
|
+
// Early returns now go AFTER every hook above so React sees a stable
|
|
928
|
+
// hook count regardless of artwork / error state.
|
|
929
|
+
if (!hasArtwork) {
|
|
930
|
+
return (
|
|
931
|
+
<div
|
|
932
|
+
className={`bg-gray-100 flex items-center justify-center ${
|
|
933
|
+
className || ""
|
|
934
|
+
}`}
|
|
935
|
+
style={style}
|
|
936
|
+
>
|
|
937
|
+
<div className="text-gray-400 text-center p-4">
|
|
938
|
+
<svg
|
|
939
|
+
className="w-8 h-8 mx-auto mb-2"
|
|
940
|
+
fill="none"
|
|
941
|
+
stroke="currentColor"
|
|
942
|
+
viewBox="0 0 24 24"
|
|
943
|
+
>
|
|
944
|
+
<path
|
|
945
|
+
strokeLinecap="round"
|
|
946
|
+
strokeLinejoin="round"
|
|
947
|
+
strokeWidth={1.5}
|
|
948
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
949
|
+
/>
|
|
950
|
+
</svg>
|
|
951
|
+
<p className="text-xs">No artwork</p>
|
|
952
|
+
</div>
|
|
953
|
+
</div>
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (error) {
|
|
958
|
+
return (
|
|
959
|
+
<div
|
|
960
|
+
className={`bg-red-50 flex items-center justify-center ${
|
|
961
|
+
className || ""
|
|
962
|
+
}`}
|
|
963
|
+
style={style}
|
|
964
|
+
>
|
|
965
|
+
<div className="text-red-600 text-center p-4">
|
|
966
|
+
<p className="text-xs">Error loading</p>
|
|
967
|
+
</div>
|
|
968
|
+
</div>
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return (
|
|
973
|
+
<div
|
|
974
|
+
ref={setContainerRef}
|
|
975
|
+
className={`relative overflow-hidden ${className || ""}`}
|
|
976
|
+
style={style}
|
|
977
|
+
data-hero-image="true"
|
|
978
|
+
>
|
|
979
|
+
{/* Loading skeleton — painted UNDER the <img> so the image covers it the
|
|
980
|
+
instant it renders, with no state-flush flash. Removed once the first
|
|
981
|
+
image has loaded; later URL changes crossfade over a visible image. */}
|
|
982
|
+
{!firstImageLoaded && (
|
|
983
|
+
<div className="absolute inset-0 bg-muted-foreground/20 animate-pulse" />
|
|
984
|
+
)}
|
|
985
|
+
|
|
986
|
+
{/* Current image — shows `renderedUrl`, which the crossfade effect only
|
|
987
|
+
advances after the next image has finished preloading. This prevents
|
|
988
|
+
the new → old → new flash that happened when displayUrl was bound
|
|
989
|
+
directly to <img src> (the browser snapped to NEW from the cache
|
|
990
|
+
before prevUrl could be staged on top, then prevUrl appeared and
|
|
991
|
+
covered NEW with OLD, then OLD faded back to NEW). */}
|
|
992
|
+
{renderedUrl && (
|
|
993
|
+
<img
|
|
994
|
+
alt={`Product mockup${placement ? ` - ${placement}` : ""}`}
|
|
995
|
+
crossOrigin="anonymous"
|
|
996
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
997
|
+
draggable={draggable}
|
|
998
|
+
src={renderedUrl}
|
|
999
|
+
loading="eager"
|
|
1000
|
+
// eslint-disable-next-line react/no-unknown-property
|
|
1001
|
+
fetchPriority="high"
|
|
1002
|
+
onClick={onClick}
|
|
1003
|
+
onLoad={() => {
|
|
1004
|
+
setFirstImageLoaded(true);
|
|
1005
|
+
if (!onLoadCalledRef.current && onLoad) {
|
|
1006
|
+
onLoadCalledRef.current = true;
|
|
1007
|
+
onLoad();
|
|
1008
|
+
}
|
|
1009
|
+
onUrlGeneratedRef.current?.(renderedUrl);
|
|
1010
|
+
}}
|
|
1011
|
+
onError={() => {
|
|
1012
|
+
setFirstImageLoaded(true);
|
|
1013
|
+
onError?.();
|
|
1014
|
+
// Dev-only guidance for the most common signed-shop trap: a mockup
|
|
1015
|
+
// <img> that loads an UNSIGNED resolver URL gets a 403 "Missing
|
|
1016
|
+
// required signature", with nothing pointing at the fix.
|
|
1017
|
+
if (
|
|
1018
|
+
process.env.NODE_ENV !== "production" &&
|
|
1019
|
+
!signHintShownRef.current &&
|
|
1020
|
+
renderedUrl &&
|
|
1021
|
+
/\/[A-Za-z0-9]+\?/.test(renderedUrl) &&
|
|
1022
|
+
!/[?&]signature=/.test(renderedUrl)
|
|
1023
|
+
) {
|
|
1024
|
+
signHintShownRef.current = true;
|
|
1025
|
+
const hasSigner =
|
|
1026
|
+
typeof window !== "undefined" &&
|
|
1027
|
+
!!(
|
|
1028
|
+
window as unknown as {
|
|
1029
|
+
snowcone?: { signMockupUrl?: unknown };
|
|
1030
|
+
}
|
|
1031
|
+
).snowcone?.signMockupUrl;
|
|
1032
|
+
console.warn(
|
|
1033
|
+
hasSigner
|
|
1034
|
+
? "[Snowcone] A mockup image failed to load and its URL is unsigned. " +
|
|
1035
|
+
"Your `signMockupUrl` likely errored — check your /api/sign route " +
|
|
1036
|
+
"and that its secret matches the shop's signing secret."
|
|
1037
|
+
: "[Snowcone] A mockup image failed to load (likely 'Missing required " +
|
|
1038
|
+
"signature'). If your shop requires signed URLs, pass `signMockupUrl` " +
|
|
1039
|
+
"to <Shop> so the UI can sign mockup <img> URLs. Docs: " +
|
|
1040
|
+
"https://developers.snowcone.app/design-system/signing-at-scale",
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
}}
|
|
1044
|
+
/>
|
|
1045
|
+
)}
|
|
1046
|
+
|
|
1047
|
+
{/* Previous image — fades out during crossfade */}
|
|
1048
|
+
{prevUrl && (
|
|
1049
|
+
<img
|
|
1050
|
+
alt=""
|
|
1051
|
+
crossOrigin="anonymous"
|
|
1052
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
1053
|
+
src={prevUrl}
|
|
1054
|
+
style={{
|
|
1055
|
+
opacity: showNew ? 0 : 1,
|
|
1056
|
+
transition: `opacity ${CROSSFADE_MS}ms ease-in-out`,
|
|
1057
|
+
pointerEvents: 'none',
|
|
1058
|
+
}}
|
|
1059
|
+
onTransitionEnd={handleCrossfadeEnd}
|
|
1060
|
+
/>
|
|
1061
|
+
)}
|
|
1062
|
+
|
|
1063
|
+
{/* SAFARI FIX: Disabled shimmer overlay - it depends on layers state which causes re-renders */}
|
|
1064
|
+
{/* {showShimmer && layers.length > 0 && (
|
|
1065
|
+
<div
|
|
1066
|
+
className="absolute inset-0 z-10 pointer-events-none bg-black/30"
|
|
1067
|
+
style={{
|
|
1068
|
+
opacity: shimmerOpacity,
|
|
1069
|
+
transition: `opacity ${
|
|
1070
|
+
shimmerOpacity === 1
|
|
1071
|
+
? durations.darkShimmerFadeIn
|
|
1072
|
+
: durations.fade
|
|
1073
|
+
}ms ease-out`,
|
|
1074
|
+
}}
|
|
1075
|
+
/>
|
|
1076
|
+
)} */}
|
|
1077
|
+
</div>
|
|
1078
|
+
);
|
|
1079
|
+
});
|