@snowcone-app/ui 0.1.43 → 0.2.1

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