@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,608 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect } from "react";
4
+
5
+ export interface UseImageTransitionOptions {
6
+ /** Duration of image crossfade in ms (default: 500) */
7
+ fadeDuration?: number;
8
+ /** Delay before showing shimmer during slow loads in ms (default: 200) */
9
+ shimmerDelay?: number;
10
+ /** Duration of shimmer fade in/out in ms for light shimmer (default: 500) */
11
+ shimmerFadeDuration?: number;
12
+ /** Duration of dark shimmer fade-in in ms (default: 300) */
13
+ darkShimmerFadeInDuration?: number;
14
+ /**
15
+ * Initial URL to display immediately without preloading.
16
+ * Use this when remounting a component that was already showing this image,
17
+ * allowing instant display without a network request or flicker.
18
+ */
19
+ initialUrl?: string | null;
20
+ }
21
+
22
+ /** A single image layer in the transition stack */
23
+ export interface ImageLayer {
24
+ url: string;
25
+ opacity: number;
26
+ id: number; // Unique ID for React key
27
+ waitForLoad?: boolean; // If true, don't auto-fade - wait for onImageLoad
28
+ loaded?: boolean; // True after img.decode() completes - controls visibility
29
+ }
30
+
31
+ export interface UseImageTransitionReturn {
32
+ // What to render - a stack of image layers (bottom to top)
33
+ layers: ImageLayer[];
34
+ shimmerOpacity: number;
35
+ showShimmer: boolean;
36
+ shimmerType: "light" | "dark";
37
+
38
+ // Control
39
+ setTargetUrl: (url: string | null, shimmerType?: "light" | "dark") => void;
40
+ /**
41
+ * Add a URL directly to layers without preloading via new Image().
42
+ * The actual <img> element in the DOM will handle loading.
43
+ * Use this when you trust the URL and want the visible element to load it.
44
+ * @param options.skipTransition - If true, add at opacity 1 immediately (no fade-in)
45
+ */
46
+ addLayerDirectly: (url: string, options?: { showShimmer?: boolean; skipTransition?: boolean }) => void;
47
+ /** Trigger shimmer immediately (e.g., when canvas export starts, before URL is known) */
48
+ triggerShimmer: (type?: "light" | "dark") => void;
49
+ /** Called when the actual img element loads - hides shimmer and triggers fade-in */
50
+ onImageLoad: (layerId: number) => void;
51
+ preloadUrl: string | null;
52
+ onPreloadComplete: (loadedUrl: string) => void;
53
+ onPreloadError: () => void;
54
+ onLayerTransitionEnd: (layerId: number) => void;
55
+
56
+ // Durations (for CSS styling)
57
+ durations: {
58
+ fade: number;
59
+ shimmerFade: number;
60
+ darkShimmerFadeIn: number;
61
+ };
62
+ }
63
+
64
+ let layerIdCounter = 0;
65
+
66
+ /**
67
+ * useImageTransition - Simple image transition hook with race condition protection
68
+ *
69
+ * Core principle: Use a monotonically increasing version number to prevent stale
70
+ * image loads from being displayed. Each call to setTargetUrl increments the version,
71
+ * and only images that match the current version are added to layers.
72
+ */
73
+ export function useImageTransition(
74
+ options: UseImageTransitionOptions = {}
75
+ ): UseImageTransitionReturn {
76
+ const {
77
+ fadeDuration = 500,
78
+ shimmerDelay = 200,
79
+ shimmerFadeDuration = 500,
80
+ darkShimmerFadeInDuration = 300,
81
+ initialUrl,
82
+ } = options;
83
+
84
+ // State - initialize with a layer if initialUrl is provided (for instant display on remount)
85
+ // When initialUrl is provided, we trust it's valid and display immediately (browser handles caching)
86
+ const [state, setState] = useState<{
87
+ layers: ImageLayer[];
88
+ preloadUrl: string | null;
89
+ shimmerOpacity: number;
90
+ showShimmer: boolean;
91
+ shimmerType: "light" | "dark";
92
+ }>(() => {
93
+ return {
94
+ layers: initialUrl
95
+ ? [{ url: initialUrl, opacity: 1, id: ++layerIdCounter, loaded: true }]
96
+ : [],
97
+ preloadUrl: null,
98
+ shimmerOpacity: 0,
99
+ showShimmer: false,
100
+ shimmerType: "light",
101
+ };
102
+ });
103
+
104
+ // Version counter - increments on every setTargetUrl call
105
+ // This is the key to preventing race conditions
106
+ const versionRef = useRef(0);
107
+
108
+ // Current preloader
109
+ const preloaderRef = useRef<HTMLImageElement | null>(null);
110
+
111
+ // Track current preload URL for synchronous deduplication
112
+ // This is needed because setState is async and we need to prevent
113
+ // duplicate loads that happen in quick succession
114
+ const currentPreloadUrlRef = useRef<string | null>(null);
115
+
116
+ // Shimmer delay timer
117
+ const shimmerTimerRef = useRef<NodeJS.Timeout | null>(null);
118
+
119
+ // Track if mounted
120
+ const isMountedRef = useRef(true);
121
+
122
+ useEffect(() => {
123
+ isMountedRef.current = true;
124
+ return () => {
125
+ isMountedRef.current = false;
126
+ if (shimmerTimerRef.current) {
127
+ clearTimeout(shimmerTimerRef.current);
128
+ }
129
+ // DON'T cancel in-flight image loads on unmount!
130
+ // Let the browser finish loading and cache the response.
131
+ // When the component remounts, it will load instantly from cache.
132
+ // We just clear the callbacks to prevent state updates on unmounted component.
133
+ if (preloaderRef.current) {
134
+ preloaderRef.current.onload = null;
135
+ preloaderRef.current.onerror = null;
136
+ // NOTE: We intentionally do NOT set src = "" here
137
+ // Setting src = "" cancels the request before it completes
138
+ preloaderRef.current = null;
139
+ }
140
+ };
141
+ }, []);
142
+
143
+ const setTargetUrl = useCallback(
144
+ (url: string | null, shimmerType: "light" | "dark" = "light") => {
145
+ // Increment version - this invalidates any in-flight preloads
146
+ const version = ++versionRef.current;
147
+
148
+ // Cancel any pending shimmer timer
149
+ if (shimmerTimerRef.current) {
150
+ clearTimeout(shimmerTimerRef.current);
151
+ shimmerTimerRef.current = null;
152
+ }
153
+
154
+ // Cancel any in-flight preload
155
+ if (preloaderRef.current) {
156
+ preloaderRef.current.onload = null;
157
+ preloaderRef.current.onerror = null;
158
+ preloaderRef.current.src = "";
159
+ preloaderRef.current = null;
160
+ currentPreloadUrlRef.current = null;
161
+ }
162
+
163
+ // Handle null/reset
164
+ if (!url) {
165
+ currentPreloadUrlRef.current = null;
166
+ setState({
167
+ layers: [],
168
+ preloadUrl: null,
169
+ shimmerOpacity: 0,
170
+ showShimmer: false,
171
+ shimmerType: "light",
172
+ });
173
+ return;
174
+ }
175
+
176
+ // Track current preload URL in a ref for synchronous deduplication
177
+ // This prevents race conditions where multiple setTargetUrl calls happen
178
+ // before React state updates
179
+ if (currentPreloadUrlRef.current === url) {
180
+ return;
181
+ }
182
+
183
+ // Check if we're already showing this URL
184
+ // Use functional update to safely check current state
185
+ let skipLoad = false;
186
+ setState((prev) => {
187
+ const topLayer = prev.layers[prev.layers.length - 1];
188
+ // Compare base URL without cache buster
189
+ const topLayerBaseUrl = topLayer?.url?.split('&_t=')[0]?.split('?_t=')[0];
190
+ if (topLayerBaseUrl === url && topLayer.opacity === 1) {
191
+ skipLoad = true;
192
+ return prev;
193
+ }
194
+
195
+ // Start loading the new URL
196
+ return {
197
+ ...prev,
198
+ preloadUrl: url,
199
+ shimmerType,
200
+ };
201
+ });
202
+
203
+ // Note: skipLoad check here is a race but the ref check above handles most cases
204
+ // The worst case is starting a redundant load, which the server should handle
205
+
206
+ // Update ref to track current preload
207
+ currentPreloadUrlRef.current = url;
208
+
209
+ // Start the preload
210
+ const img = new Image();
211
+ img.crossOrigin = 'anonymous';
212
+ preloaderRef.current = img;
213
+
214
+ img.onload = () => {
215
+ // Clear the preload tracking ref
216
+ if (currentPreloadUrlRef.current === url) {
217
+ currentPreloadUrlRef.current = null;
218
+ }
219
+
220
+ // CRITICAL: Only accept this image if it's still the current version
221
+ if (version !== versionRef.current) {
222
+ return;
223
+ }
224
+
225
+ if (!isMountedRef.current) return;
226
+
227
+ // Cancel shimmer timer since image loaded
228
+ if (shimmerTimerRef.current) {
229
+ clearTimeout(shimmerTimerRef.current);
230
+ shimmerTimerRef.current = null;
231
+ }
232
+
233
+ setState((prev) => {
234
+ // Double-check version inside setState for extra safety
235
+ if (version !== versionRef.current) {
236
+ return prev;
237
+ }
238
+
239
+ const newLayer: ImageLayer = {
240
+ url,
241
+ opacity: 0,
242
+ id: ++layerIdCounter,
243
+ loaded: false,
244
+ };
245
+
246
+ // Keep max 2 layers for performance
247
+ let newLayers: ImageLayer[];
248
+ if (prev.layers.length >= 2) {
249
+ const baseLayer = prev.layers.find((l) => l.opacity === 1) || prev.layers[0];
250
+ newLayers = baseLayer ? [baseLayer, newLayer] : [newLayer];
251
+ } else {
252
+ newLayers = [...prev.layers, newLayer];
253
+ }
254
+
255
+ return {
256
+ ...prev,
257
+ layers: newLayers,
258
+ preloadUrl: null,
259
+ shimmerOpacity: 0,
260
+ };
261
+ });
262
+ };
263
+
264
+ img.onerror = () => {
265
+ // Check if this load is still relevant
266
+ if (version !== versionRef.current) {
267
+ return;
268
+ }
269
+ if (!isMountedRef.current) return;
270
+
271
+ // Clear the preload tracking ref
272
+ if (currentPreloadUrlRef.current === url) {
273
+ currentPreloadUrlRef.current = null;
274
+ }
275
+
276
+ // DON'T hide shimmer on error!
277
+ // The URL might have been invalidated because a NEWER one is coming.
278
+ // Keep showing shimmer (or existing image) until a successful load.
279
+ setState((prev) => {
280
+ // If we have no layers (no image showing), show shimmer to indicate loading
281
+ // If we have layers, keep showing the existing image
282
+ const shouldShowShimmer = prev.layers.length === 0 || prev.showShimmer;
283
+ return {
284
+ ...prev,
285
+ preloadUrl: null,
286
+ showShimmer: shouldShowShimmer,
287
+ shimmerOpacity: shouldShowShimmer ? 1 : 0,
288
+ shimmerType: prev.layers.length > 0 ? "dark" : "light",
289
+ };
290
+ });
291
+ };
292
+
293
+ img.src = url;
294
+
295
+ // Set up shimmer timer (show shimmer if load takes too long)
296
+ shimmerTimerRef.current = setTimeout(() => {
297
+ if (version !== versionRef.current) return;
298
+ if (!isMountedRef.current) return;
299
+
300
+ setState((prev) => {
301
+ if (!prev.preloadUrl) return prev; // Already loaded
302
+ return {
303
+ ...prev,
304
+ showShimmer: true,
305
+ shimmerOpacity: 0,
306
+ };
307
+ });
308
+ }, shimmerDelay);
309
+ },
310
+ [shimmerDelay]
311
+ );
312
+
313
+ // Trigger fade-in when new layer is added at opacity 0
314
+ // Skip layers with waitForLoad - they will fade in via onImageLoad
315
+ useEffect(() => {
316
+ const topLayer = state.layers[state.layers.length - 1];
317
+ if (topLayer && topLayer.opacity === 0 && !topLayer.waitForLoad) {
318
+ const targetId = topLayer.id;
319
+
320
+ // Double RAF for browser paint
321
+ const raf1 = requestAnimationFrame(() => {
322
+ if (!isMountedRef.current) return;
323
+ requestAnimationFrame(() => {
324
+ if (!isMountedRef.current) return;
325
+ setState((prev) => {
326
+ const idx = prev.layers.findIndex((l) => l.id === targetId);
327
+ if (idx === -1 || prev.layers[idx].opacity === 1) return prev;
328
+
329
+ const newLayers = [...prev.layers];
330
+ newLayers[idx] = { ...newLayers[idx], opacity: 1 };
331
+ return { ...prev, layers: newLayers };
332
+ });
333
+ });
334
+ });
335
+
336
+ return () => cancelAnimationFrame(raf1);
337
+ }
338
+ }, [state.layers]);
339
+
340
+ // Trigger shimmer fade-in
341
+ useEffect(() => {
342
+ if (state.showShimmer && state.shimmerOpacity === 0 && state.preloadUrl) {
343
+ const raf = requestAnimationFrame(() => {
344
+ if (!isMountedRef.current) return;
345
+ requestAnimationFrame(() => {
346
+ if (!isMountedRef.current) return;
347
+ setState((prev) => {
348
+ if (!prev.showShimmer || !prev.preloadUrl) return prev;
349
+ return { ...prev, shimmerOpacity: 1 };
350
+ });
351
+ });
352
+ });
353
+ return () => cancelAnimationFrame(raf);
354
+ }
355
+ }, [state.showShimmer, state.shimmerOpacity, state.preloadUrl]);
356
+
357
+ // Fade out shimmer when load completes
358
+ useEffect(() => {
359
+ if (state.showShimmer && state.shimmerOpacity === 0 && !state.preloadUrl) {
360
+ const duration = state.shimmerType === "dark" ? fadeDuration : shimmerFadeDuration;
361
+ const timer = setTimeout(() => {
362
+ setState((prev) => ({ ...prev, showShimmer: false }));
363
+ }, duration);
364
+ return () => clearTimeout(timer);
365
+ }
366
+ }, [state.showShimmer, state.shimmerOpacity, state.preloadUrl, state.shimmerType, fadeDuration, shimmerFadeDuration]);
367
+
368
+ const onLayerTransitionEnd = useCallback((layerId: number) => {
369
+ setState((prev) => {
370
+ const idx = prev.layers.findIndex((l) => l.id === layerId);
371
+ if (idx === -1 || prev.layers[idx].opacity !== 1) return prev;
372
+
373
+ // If this is the top layer and it's fully visible, remove layers below
374
+ if (idx === prev.layers.length - 1 && idx > 0) {
375
+ return {
376
+ ...prev,
377
+ layers: [prev.layers[idx]],
378
+ shimmerOpacity: 0,
379
+ };
380
+ }
381
+ return prev;
382
+ });
383
+ }, []);
384
+
385
+ // Legacy callbacks (for compatibility)
386
+ const onPreloadComplete = useCallback((_loadedUrl: string) => {
387
+ // Legacy callback - kept for API compatibility
388
+ }, []);
389
+
390
+ const onPreloadError = useCallback(() => {
391
+ // Legacy callback - kept for API compatibility
392
+ }, []);
393
+
394
+ // Add URL directly to layers without preloading via new Image()
395
+ // Instead, we create a hidden Image and call decode() to ensure Safari
396
+ // decodes the image BEFORE it enters the viewport (fixes Safari flash bug)
397
+ const addLayerDirectly = useCallback((url: string, options?: { showShimmer?: boolean; skipTransition?: boolean }) => {
398
+ const shouldShowShimmer = options?.showShimmer ?? true;
399
+ const skipTransition = options?.skipTransition ?? false;
400
+
401
+ // Increment version to invalidate any in-flight preloads
402
+ const currentVersion = ++versionRef.current;
403
+
404
+ // Cancel any pending preload
405
+ if (preloaderRef.current) {
406
+ preloaderRef.current.onload = null;
407
+ preloaderRef.current.onerror = null;
408
+ preloaderRef.current.src = "";
409
+ preloaderRef.current = null;
410
+ currentPreloadUrlRef.current = null;
411
+ }
412
+
413
+ // Cancel shimmer timer
414
+ if (shimmerTimerRef.current) {
415
+ clearTimeout(shimmerTimerRef.current);
416
+ shimmerTimerRef.current = null;
417
+ }
418
+
419
+ const directUrl = url;
420
+
421
+ // Track the layer ID we're about to create
422
+ const newLayerId = layerIdCounter + 1;
423
+
424
+ setState((prev) => {
425
+ // Check if we already have this URL at opacity 1 (compare base URL without cache buster)
426
+ const topLayer = prev.layers[prev.layers.length - 1];
427
+ const topLayerBaseUrl = topLayer?.url?.split('&_t=')[0]?.split('?_t=')[0];
428
+ // Deduplicate if we're already loading OR showing this URL
429
+ // (Previously only checked opacity === 1, which caused duplicate layers during rapid updates)
430
+ if (topLayerBaseUrl === url) {
431
+ return prev;
432
+ }
433
+
434
+ // Add new layer - either at opacity 0 (with fade-in) or opacity 1 (instant, for cached images)
435
+ // SAFARI FIX: When skipTransition is true, set loaded: true immediately to avoid
436
+ // any decode() calls or state changes that could cause flash on scroll.
437
+ const newLayer: ImageLayer = {
438
+ url: directUrl,
439
+ opacity: skipTransition ? 1 : 0,
440
+ id: ++layerIdCounter,
441
+ waitForLoad: skipTransition ? false : true,
442
+ loaded: skipTransition ? true : false,
443
+ };
444
+
445
+ // Keep max 2 layers
446
+ let newLayers: ImageLayer[];
447
+ if (prev.layers.length >= 2) {
448
+ const baseLayer = prev.layers.find((l) => l.opacity === 1) || prev.layers[0];
449
+ newLayers = baseLayer ? [baseLayer, newLayer] : [newLayer];
450
+ } else {
451
+ newLayers = [...prev.layers, newLayer];
452
+ }
453
+
454
+ // Preserve shimmer state - don't reset it here
455
+ // Shimmer will be hidden by onImageLoad when the img finishes loading
456
+ return {
457
+ ...prev,
458
+ layers: newLayers,
459
+ };
460
+ });
461
+
462
+ // SAFARI FIX: Skip decode() for skipTransition - avoids state changes that cause flash.
463
+ // When skipTransition is true, we trust the image is cached/ready and display it immediately.
464
+ // The decode() call is only needed for artwork changes where we want to ensure the new
465
+ // image is ready before showing it.
466
+ if (!skipTransition) {
467
+ // Proactively decode the image using a hidden Image element.
468
+ // Safari doesn't fire onLoad for off-screen images until they scroll into view,
469
+ // which causes a flash. By calling decode() here, we force Safari to decode
470
+ // the image immediately, and then mark it as loaded before it's visible.
471
+ const preloadImg = new Image();
472
+ preloadImg.crossOrigin = 'anonymous';
473
+ preloadImg.src = directUrl;
474
+
475
+ const markAsLoaded = () => {
476
+ // Check version to avoid stale updates
477
+ if (currentVersion !== versionRef.current) return;
478
+ if (!isMountedRef.current) return;
479
+
480
+ // Cancel shimmer timer since image is ready
481
+ if (shimmerTimerRef.current) {
482
+ clearTimeout(shimmerTimerRef.current);
483
+ shimmerTimerRef.current = null;
484
+ }
485
+
486
+ setState((prev) => {
487
+ const idx = prev.layers.findIndex((l) => l.id === newLayerId);
488
+ if (idx === -1 || prev.layers[idx].loaded) return prev;
489
+
490
+ const newLayers = [...prev.layers];
491
+ newLayers[idx] = { ...newLayers[idx], loaded: true, opacity: 1 };
492
+ return {
493
+ ...prev,
494
+ layers: newLayers,
495
+ preloadUrl: null,
496
+ shimmerOpacity: 0,
497
+ };
498
+ });
499
+ };
500
+
501
+ // Use decode() if available (modern browsers), otherwise fall back to onload
502
+ if (preloadImg.decode) {
503
+ preloadImg.decode().then(markAsLoaded).catch(markAsLoaded);
504
+ } else {
505
+ preloadImg.onload = markAsLoaded;
506
+ preloadImg.onerror = markAsLoaded;
507
+ }
508
+ }
509
+
510
+ // Start delayed shimmer timer - only show shimmer if image takes > 500ms to load
511
+ // This prevents flashing the dark overlay for cached/fast images
512
+ if (shouldShowShimmer) {
513
+ shimmerTimerRef.current = setTimeout(() => {
514
+ if (!isMountedRef.current) return;
515
+ if (currentVersion !== versionRef.current) return;
516
+ setState((prev) => {
517
+ // Only show shimmer if we still have a loading layer
518
+ const hasUnloadedLayer = prev.layers.some(l => !l.loaded);
519
+ if (!hasUnloadedLayer) return prev;
520
+
521
+ return {
522
+ ...prev,
523
+ showShimmer: true,
524
+ shimmerOpacity: 0,
525
+ shimmerType: "dark",
526
+ preloadUrl: "__pending__",
527
+ };
528
+ });
529
+ }, 500);
530
+ }
531
+ }, []);
532
+
533
+ // Trigger shimmer immediately (for early feedback before URL is known)
534
+ const triggerShimmer = useCallback((type: "light" | "dark" = "dark") => {
535
+ setState((prev) => {
536
+ // Only trigger if we have existing layers (dark shimmer on top of content)
537
+ if (prev.layers.length === 0) {
538
+ return prev;
539
+ }
540
+
541
+ // If shimmer is already showing AND visible (opacity 1), skip
542
+ // But if shimmer was fading out (opacity 0), re-trigger it
543
+ if (prev.showShimmer && prev.shimmerOpacity === 1) {
544
+ return prev;
545
+ }
546
+
547
+ return {
548
+ ...prev,
549
+ showShimmer: true,
550
+ shimmerOpacity: 0,
551
+ shimmerType: type,
552
+ // Set a placeholder preloadUrl to keep shimmer visible
553
+ // This will be replaced when setTargetUrl is called with the real URL
554
+ preloadUrl: prev.preloadUrl || "__pending__",
555
+ };
556
+ });
557
+ }, []);
558
+
559
+ // Called when the actual img element loads - hides shimmer AND triggers fade-in
560
+ const onImageLoad = useCallback((layerId: number) => {
561
+ // Cancel shimmer timer - image loaded before the 500ms delay
562
+ if (shimmerTimerRef.current) {
563
+ clearTimeout(shimmerTimerRef.current);
564
+ shimmerTimerRef.current = null;
565
+ }
566
+
567
+ setState((prev) => {
568
+ // Find the layer
569
+ const idx = prev.layers.findIndex((l) => l.id === layerId);
570
+ if (idx === -1) {
571
+ return prev;
572
+ }
573
+
574
+ // Set this layer to opacity 1 (will trigger CSS transition)
575
+ // and mark as loaded (for visibility control) since decode() completed
576
+ // Also hide shimmer since image is loaded
577
+ const newLayers = [...prev.layers];
578
+ newLayers[idx] = { ...newLayers[idx], opacity: 1, loaded: true };
579
+
580
+ return {
581
+ ...prev,
582
+ layers: newLayers,
583
+ preloadUrl: null,
584
+ shimmerOpacity: 0,
585
+ };
586
+ });
587
+ }, []);
588
+
589
+ return {
590
+ layers: state.layers,
591
+ shimmerOpacity: state.shimmerOpacity,
592
+ showShimmer: state.showShimmer,
593
+ shimmerType: state.shimmerType,
594
+ setTargetUrl,
595
+ addLayerDirectly,
596
+ triggerShimmer,
597
+ onImageLoad,
598
+ preloadUrl: state.preloadUrl,
599
+ onPreloadComplete,
600
+ onPreloadError,
601
+ onLayerTransitionEnd,
602
+ durations: {
603
+ fade: fadeDuration,
604
+ shimmerFade: shimmerFadeDuration,
605
+ darkShimmerFadeIn: darkShimmerFadeInDuration,
606
+ },
607
+ };
608
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import type { DesignElement } from "@snowcone-app/sdk";
5
+
6
+ /**
7
+ * usePlacementsProcessor - Process placement prop to separate colors from images
8
+ *
9
+ * Takes a placements object where values can be either color codes or image URLs,
10
+ * and separates them into distinct arrays/objects for easier processing.
11
+ *
12
+ * **Color Detection:**
13
+ * - Hex colors: `#fff`, `#FF00FF`
14
+ * - RGB/RGBA: `rgb(255, 0, 0)`, `rgba(255, 0, 0, 0.5)`
15
+ * - HSL/HSLA: `hsl(120, 100%, 50%)`, `hsla(120, 100%, 50%, 0.3)`
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * const { processedImages, colorPlacements } = usePlacementsProcessor({
20
+ * Front: 'https://example.com/art.jpg',
21
+ * Back: '#FF0000',
22
+ * Sleeve: 'rgb(0, 255, 0)'
23
+ * }, []);
24
+ *
25
+ * // Result:
26
+ * // processedImages = [{ placement: 'Front', imageUrl: 'https://example.com/art.jpg' }]
27
+ * // colorPlacements = { Back: '#FF0000', Sleeve: 'rgb(0, 255, 0)' }
28
+ * ```
29
+ *
30
+ * @param placements - Object mapping placement labels to color codes or image URLs
31
+ * @param fallbackImages - Images array to return if no placements provided
32
+ * @returns Object with processedImages array and colorPlacements object
33
+ */
34
+ export function usePlacementsProcessor(
35
+ placements: Record<string, string> | undefined,
36
+ fallbackImages: DesignElement[]
37
+ ): {
38
+ processedImages: DesignElement[];
39
+ colorPlacements: Record<string, string> | null;
40
+ } {
41
+ return useMemo(() => {
42
+ if (!placements) {
43
+ return { processedImages: fallbackImages, colorPlacements: null };
44
+ }
45
+
46
+ const imgPlacements: DesignElement[] = [];
47
+ const colors: Record<string, string> = {};
48
+
49
+ // Helper to detect if value is a color (hex, rgb, rgba, hsl, hsla)
50
+ const isColor = (value: string): boolean => {
51
+ return (
52
+ /^#([0-9A-F]{3}){1,2}$/i.test(value) || // hex
53
+ /^rgb/.test(value) || // rgb/rgba
54
+ /^hsl/.test(value) // hsl/hsla
55
+ );
56
+ };
57
+
58
+ Object.entries(placements).forEach(([placementLabel, value]) => {
59
+ if (isColor(value)) {
60
+ colors[placementLabel] = value;
61
+ } else {
62
+ imgPlacements.push({
63
+ placement: placementLabel,
64
+ imageUrl: value,
65
+ });
66
+ }
67
+ });
68
+
69
+ return {
70
+ processedImages: imgPlacements.length > 0 ? imgPlacements : fallbackImages,
71
+ colorPlacements: Object.keys(colors).length > 0 ? colors : null,
72
+ };
73
+ }, [placements, fallbackImages]);
74
+ }