@snowcone-app/ui 0.1.43 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
@@ -0,0 +1,1014 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MockupPriorityProvider - Priority-based mockup rendering coordinator
5
+ *
6
+ * Manages a 3-level priority system for mockup rendering:
7
+ * - Level 1: Single most visible mockup (highest priority, pauses others)
8
+ * - Level 2: Other visible/partially visible mockups
9
+ * - Level 3: Off-screen mockups (max 5 prefetch, sorted by likelihood)
10
+ *
11
+ * Architecture:
12
+ * - Uses refs for visibility state to avoid re-renders during scroll
13
+ * - Only triggers state update when Level 1 mockup changes
14
+ * - Provides subscription system for components that need reactive updates
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <RealtimeProvider>
19
+ * <MockupPriorityProvider>
20
+ * <HeroProductImage mockupId="front" />
21
+ * <HeroProductImage mockupId="back" />
22
+ * </MockupPriorityProvider>
23
+ * </RealtimeProvider>
24
+ * ```
25
+ */
26
+
27
+ import React, {
28
+ createContext,
29
+ useContext,
30
+ useCallback,
31
+ useRef,
32
+ useMemo,
33
+ useState,
34
+ useEffect,
35
+ } from 'react';
36
+ import {
37
+ useScrollDirection,
38
+ createScrollDirectionTracker,
39
+ type ScrollDirection,
40
+ } from '../hooks/useScrollDirection';
41
+ import { useRealtimeOptional } from './RealtimeProvider';
42
+
43
+ // Constants
44
+ const MAX_LEVEL3_PREFETCH = 5;
45
+ const PRIORITY_RECALC_DEBOUNCE_MS = 50;
46
+ const PRELOAD_MARGIN_PX = 750; // Load images 750px before they enter viewport
47
+
48
+ // Types
49
+ export type PriorityLevel = 1 | 2 | 3;
50
+
51
+ export interface VisibilityInfo {
52
+ mockupId: string;
53
+ placement: string;
54
+ intersectionRatio: number;
55
+ visibleArea: number;
56
+ boundingRect: DOMRectReadOnly | null;
57
+ distanceFromCenter: number;
58
+ isAboveViewport: boolean;
59
+ lastUpdated: number;
60
+ /** True if element is within PRELOAD_MARGIN_PX of viewport (for preloading before visible) */
61
+ isInPreloadZone: boolean;
62
+ }
63
+
64
+ export interface MockupPriorityContextValue {
65
+ // Registration - pass element ref for scroll-based visibility calculation
66
+ registerMockup: (
67
+ mockupId: string,
68
+ placement: string,
69
+ initialPriority?: PriorityLevel,
70
+ elementRef?: HTMLElement | null
71
+ ) => void;
72
+ unregisterMockup: (mockupId: string) => void;
73
+
74
+ // Update element ref (if element mounts after registration)
75
+ updateElementRef: (mockupId: string, element: HTMLElement | null) => void;
76
+
77
+ // Legacy: kept for compatibility but no longer needed with scroll-based approach
78
+ reportVisibility: (
79
+ mockupId: string,
80
+ ratio: number,
81
+ boundingRect: DOMRectReadOnly
82
+ ) => void;
83
+
84
+ // Mobile carousel integration
85
+ reportMobileCarouselIndex: (currentIndex: number, mockupIds: string[]) => void;
86
+ clearMobileCarouselMode: () => void;
87
+
88
+ // Priority queries (synchronous, reads from refs)
89
+ getPriorityLevel: (mockupId: string) => PriorityLevel;
90
+ /**
91
+ * Get numeric priority for Shop queue (100 for L1, 50 for L2, 10 for L3).
92
+ * Used by ProductImage to integrate with Shop's priority queue in non-realtime mode.
93
+ */
94
+ getQueuePriority: (mockupId: string) => number;
95
+ getLevel1Mockup: () => string | null;
96
+ getLevel2Mockups: () => string[];
97
+ getLevel3Queue: () => string[];
98
+ getVisibility: (mockupId: string) => VisibilityInfo | null;
99
+
100
+ /**
101
+ * Get all mockupIds for a specific priority level.
102
+ * Returns array of mockupIds that should be rendered at that level.
103
+ */
104
+ getMockupIdsForLevel: (level: PriorityLevel) => string[];
105
+
106
+ // Pause state (for RealtimeProvider)
107
+ shouldPauseLevel: (level: 2 | 3) => boolean;
108
+ isLevel1Active: () => boolean;
109
+
110
+ // Subscriptions for reactive updates (optional)
111
+ subscribePriorityChange: (
112
+ mockupId: string,
113
+ callback: (level: PriorityLevel) => void
114
+ ) => () => void;
115
+
116
+ // Subscribe to Level 1 changes (for RealtimeProvider)
117
+ subscribeLevel1Change: (
118
+ callback: (mockupId: string | null) => void
119
+ ) => () => void;
120
+ }
121
+
122
+ const MockupPriorityContext = createContext<MockupPriorityContextValue | undefined>(
123
+ undefined
124
+ );
125
+
126
+ export interface MockupPriorityProviderProps {
127
+ children: React.ReactNode;
128
+ }
129
+
130
+ export function MockupPriorityProvider({ children }: MockupPriorityProviderProps) {
131
+ // Log on mount
132
+ useEffect(() => {
133
+ return () => {
134
+ };
135
+ }, []);
136
+
137
+ // REF-BASED STATE (no re-renders on visibility changes)
138
+ const visibilityMapRef = useRef<Map<string, VisibilityInfo>>(new Map());
139
+ const prioritiesRef = useRef<Map<string, PriorityLevel>>(new Map());
140
+ const level1MockupRef = useRef<string | null>(null);
141
+ const level2MockupsRef = useRef<string[]>([]);
142
+ const level3QueueRef = useRef<string[]>([]);
143
+
144
+ // Mobile carousel state - when set, uses index-based priority instead of visibility
145
+ const mobileCarouselRef = useRef<{
146
+ currentIndex: number;
147
+ mockupIds: string[];
148
+ } | null>(null);
149
+
150
+ // Track registration order for index-based L2 assignment (desktop scroll mode)
151
+ const registrationOrderRef = useRef<string[]>([]);
152
+
153
+ // Store element refs for scroll-based visibility calculation
154
+ const elementRefsRef = useRef<Map<string, HTMLElement>>(new Map());
155
+
156
+ // Subscriptions for reactive updates
157
+ const prioritySubscribersRef = useRef<Map<string, Set<(level: PriorityLevel) => void>>>(
158
+ new Map()
159
+ );
160
+ const level1SubscribersRef = useRef<Set<(mockupId: string | null) => void>>(
161
+ new Set()
162
+ );
163
+
164
+ // Scroll direction for Level 3 ordering
165
+ const scrollState = useScrollDirection();
166
+ const scrollDirectionRef = useRef<ScrollDirection>('idle');
167
+ scrollDirectionRef.current = scrollState.direction;
168
+
169
+ // Debounce timer for priority recalculation
170
+ const recalcTimeoutRef = useRef<NodeJS.Timeout | null>(null);
171
+ // Track pending rAF for first-visible recalculation (to avoid multiple rAF calls)
172
+ const pendingRafRef = useRef<number | null>(null);
173
+
174
+ // STATE: Only track Level 1 for minimal re-renders (used by RealtimeProvider)
175
+ const [level1MockupId, setLevel1MockupId] = useState<string | null>(null);
176
+
177
+ // Access RealtimeProvider for priority-based mockup requests
178
+ const realtimeContext = useRealtimeOptional();
179
+
180
+ // Priority rendering state
181
+ const currentRenderingLevelRef = useRef<PriorityLevel | null>(null);
182
+ const pendingMockupsRef = useRef<Set<string>>(new Set());
183
+ const renderedMockupsRef = useRef<Set<string>>(new Set());
184
+
185
+ // Track pending blob timestamps to prevent premature level advancement during drag
186
+ const lastPendingBlobTimeRef = useRef<number>(0);
187
+ const DRAG_STOP_DEBOUNCE_MS = 250; // Time to wait after last blob before advancing to Level 2+
188
+ const BLOB_SEND_DELAY_MS = 100; // Short delay to let blob be sent before requesting mockups
189
+
190
+ // Timer for delayed mockup request after drag stops
191
+ const pendingBlobRequestTimeoutRef = useRef<NodeJS.Timeout | null>(null);
192
+
193
+ /**
194
+ * Request mockups for a specific priority level.
195
+ * Uses updateMockupIds to tell server which mockups to render.
196
+ */
197
+ const requestMockupsForLevel = useCallback((level: PriorityLevel) => {
198
+
199
+ if (!realtimeContext?.updateMockupIds) {
200
+ return;
201
+ }
202
+
203
+ let mockupIds: string[];
204
+ switch (level) {
205
+ case 1:
206
+ mockupIds = level1MockupRef.current ? [level1MockupRef.current] : [];
207
+ break;
208
+ case 2:
209
+ mockupIds = [...level2MockupsRef.current];
210
+ break;
211
+ case 3:
212
+ mockupIds = [...level3QueueRef.current];
213
+ break;
214
+ default:
215
+ mockupIds = [];
216
+ }
217
+
218
+ if (mockupIds.length === 0) {
219
+ // No mockups at this level, advance to next
220
+ if (level < 3) {
221
+ requestMockupsForLevel((level + 1) as PriorityLevel);
222
+ }
223
+ return;
224
+ }
225
+
226
+ currentRenderingLevelRef.current = level;
227
+ pendingMockupsRef.current = new Set(mockupIds);
228
+ realtimeContext.updateMockupIds(mockupIds);
229
+ }, [realtimeContext]);
230
+
231
+ // Timer ref for delayed level advancement
232
+ const levelAdvanceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
233
+
234
+ /**
235
+ * Handle mockup result - check if current level is complete
236
+ */
237
+ const handleMockupResult = useCallback((mockupId: string) => {
238
+ // Mark as rendered
239
+ renderedMockupsRef.current.add(mockupId);
240
+ pendingMockupsRef.current.delete(mockupId);
241
+
242
+ const currentLevel = currentRenderingLevelRef.current;
243
+ if (currentLevel === null) return;
244
+
245
+ // Check if all pending mockups for current level are done
246
+ if (pendingMockupsRef.current.size === 0) {
247
+ const timeSinceLastPendingBlob = Date.now() - lastPendingBlobTimeRef.current;
248
+ const isStillDragging = timeSinceLastPendingBlob < DRAG_STOP_DEBOUNCE_MS;
249
+
250
+
251
+ // Level 1 complete - decide whether to advance to Level 2+
252
+ if (currentLevel === 1 && isStillDragging) {
253
+ // Still dragging - stay on Level 1, don't advance to Level 2
254
+ // The next pending blob will re-request Level 1
255
+ currentRenderingLevelRef.current = null; // Ready for next Level 1 request
256
+ return;
257
+ }
258
+
259
+ if (isStillDragging) {
260
+ // For Level 2/3 during drag, delay advancement
261
+
262
+ if (levelAdvanceTimeoutRef.current) {
263
+ clearTimeout(levelAdvanceTimeoutRef.current);
264
+ }
265
+
266
+ levelAdvanceTimeoutRef.current = setTimeout(() => {
267
+ const newTimeSince = Date.now() - lastPendingBlobTimeRef.current;
268
+ if (newTimeSince >= DRAG_STOP_DEBOUNCE_MS && pendingMockupsRef.current.size === 0) {
269
+ if (currentLevel < 3) {
270
+ requestMockupsForLevel((currentLevel + 1) as PriorityLevel);
271
+ } else {
272
+ currentRenderingLevelRef.current = null;
273
+ }
274
+ }
275
+ levelAdvanceTimeoutRef.current = null;
276
+ }, DRAG_STOP_DEBOUNCE_MS - timeSinceLastPendingBlob + 10);
277
+
278
+ return;
279
+ }
280
+
281
+ // Not dragging - advance immediately
282
+ if (currentLevel < 3) {
283
+ requestMockupsForLevel((currentLevel + 1) as PriorityLevel);
284
+ } else {
285
+ currentRenderingLevelRef.current = null;
286
+ }
287
+ }
288
+ }, [requestMockupsForLevel]);
289
+
290
+ // Subscribe to mockup results for level advancement
291
+ useEffect(() => {
292
+ if (!realtimeContext?.subscribeMockupResults) return;
293
+
294
+ const unsubscribe = realtimeContext.subscribeMockupResults((results) => {
295
+ results.forEach((result) => {
296
+ handleMockupResult(result.mockupId);
297
+ });
298
+ });
299
+
300
+ return unsubscribe;
301
+ }, [realtimeContext, handleMockupResult]);
302
+
303
+ // Log when realtimeContext changes and trigger priority flow when isConfigured becomes true
304
+ useEffect(() => {
305
+
306
+ // When isConfigured becomes true and we have a Level 1 mockup, start priority flow
307
+ if (realtimeContext?.isConfigured && level1MockupRef.current) {
308
+ renderedMockupsRef.current.clear();
309
+ requestMockupsForLevel(1);
310
+ }
311
+ }, [realtimeContext, requestMockupsForLevel]);
312
+
313
+ // When Level 1 changes, restart the priority flow (only if realtime is configured)
314
+ useEffect(() => {
315
+
316
+ if (level1MockupId === null) {
317
+ return;
318
+ }
319
+
320
+ // Wait until realtime is configured before starting priority flow
321
+ if (!realtimeContext?.isConfigured) {
322
+ return;
323
+ }
324
+
325
+ // Reset rendered tracking for new priority calculation
326
+ renderedMockupsRef.current.clear();
327
+
328
+ // Start requesting from Level 1
329
+ requestMockupsForLevel(1);
330
+ }, [level1MockupId, realtimeContext?.isConfigured, requestMockupsForLevel]);
331
+
332
+ // Subscribe to pending blob notifications to track drag timing (for L2+ delay)
333
+ useEffect(() => {
334
+ if (!realtimeContext?.subscribePendingBlob) return;
335
+
336
+ const handlePendingBlob = (placement: string) => {
337
+
338
+ // Track the time of this pending blob event for drag detection
339
+ lastPendingBlobTimeRef.current = Date.now();
340
+
341
+ // Cancel any pending level advancement - user is still making changes
342
+ if (levelAdvanceTimeoutRef.current) {
343
+ clearTimeout(levelAdvanceTimeoutRef.current);
344
+ levelAdvanceTimeoutRef.current = null;
345
+ }
346
+
347
+ // NOTE: Do NOT reset currentRenderingLevelRef, pendingMockupsRef, or renderedMockupsRef here!
348
+ // This handler fires repeatedly during drag, and resetting state here would cause
349
+ // in-flight L1 requests to be ignored when their results arrive.
350
+ // The state reset happens in handleBlobReceived when we actually start a new request.
351
+ };
352
+
353
+ const unsubscribe = realtimeContext.subscribePendingBlob(handlePendingBlob);
354
+ return unsubscribe;
355
+ }, [realtimeContext]);
356
+
357
+ // Subscribe to blob sent notifications to trigger mockup requests
358
+ // This fires when the blob is actually sent (after throttle completes)
359
+ // More reliable than blob_received which only fires for NEW placements
360
+ useEffect(() => {
361
+ if (!realtimeContext?.subscribeBlobSent) return;
362
+
363
+ const handleBlobSent = (placement: string) => {
364
+
365
+ // Cancel any pending mockup request timeout (shouldn't happen, but just in case)
366
+ if (pendingBlobRequestTimeoutRef.current) {
367
+ clearTimeout(pendingBlobRequestTimeoutRef.current);
368
+ pendingBlobRequestTimeoutRef.current = null;
369
+ }
370
+
371
+ // Reset state for new priority flow - we're starting fresh with the new blob
372
+ currentRenderingLevelRef.current = null;
373
+ pendingMockupsRef.current.clear();
374
+ renderedMockupsRef.current.clear();
375
+
376
+ // NOTE: Do NOT update lastPendingBlobTimeRef here!
377
+ // That timestamp tracks when the user is actively dragging (pendingBlob events).
378
+ // blob_sent happens after throttle delay, and we use the
379
+ // time since last pendingBlob to detect if user stopped dragging.
380
+ // If we reset it here, L1 might complete within 250ms and we'd think
381
+ // we're still dragging, preventing advancement to L2.
382
+
383
+ // Request Level 1 mockups immediately - blob is now on its way to server
384
+ if (level1MockupRef.current) {
385
+ requestMockupsForLevel(1);
386
+ }
387
+ };
388
+
389
+ const unsubscribe = realtimeContext.subscribeBlobSent(handleBlobSent);
390
+ return unsubscribe;
391
+ }, [realtimeContext, requestMockupsForLevel]);
392
+
393
+ /**
394
+ * Recalculate priorities based on current state.
395
+ *
396
+ * MOBILE CAROUSEL MODE (simple index-based):
397
+ * - Level 1: Current carousel item
398
+ * - Level 2: Adjacent items (±1)
399
+ * - Level 3: All other items
400
+ *
401
+ * DESKTOP MODE (visibility-based):
402
+ * - Level 1: Mockup with largest visible pixel area
403
+ * - Level 2: Other visible mockups
404
+ * - Level 3: Off-screen mockups
405
+ */
406
+ const recalculatePriorities = useCallback(() => {
407
+ const mobileState = mobileCarouselRef.current;
408
+
409
+ // ===== MOBILE CAROUSEL MODE =====
410
+ if (mobileState && mobileState.mockupIds.length > 0) {
411
+ const { currentIndex, mockupIds } = mobileState;
412
+
413
+ let newLevel1: string | null = null;
414
+ const newLevel2Set = new Set<string>();
415
+ const newLevel3Set = new Set<string>();
416
+ const changedMockups: Array<{ mockupId: string; newLevel: PriorityLevel }> = [];
417
+
418
+ // Calculate new priorities based on carousel index
419
+ mockupIds.forEach((id, index) => {
420
+ const oldLevel = prioritiesRef.current.get(id);
421
+ let newLevel: PriorityLevel;
422
+
423
+ if (index === currentIndex) {
424
+ newLevel = 1;
425
+ } else if (Math.abs(index - currentIndex) === 1) {
426
+ newLevel = 2;
427
+ } else {
428
+ newLevel = 3;
429
+ }
430
+
431
+ // Always set the priority (carousel index is authoritative)
432
+ prioritiesRef.current.set(id, newLevel);
433
+
434
+ if (newLevel === 1) {
435
+ newLevel1 = id;
436
+ } else if (newLevel === 2) {
437
+ newLevel2Set.add(id);
438
+ } else {
439
+ newLevel3Set.add(id);
440
+ }
441
+
442
+ // Track if priority actually changed (for subscriber notification)
443
+ if (oldLevel !== newLevel) {
444
+ changedMockups.push({ mockupId: id, newLevel });
445
+ }
446
+ });
447
+
448
+ level2MockupsRef.current = [...newLevel2Set];
449
+ level3QueueRef.current = [...newLevel3Set].slice(0, MAX_LEVEL3_PREFETCH);
450
+
451
+ // Update Level 1 if changed
452
+ if (newLevel1 !== level1MockupRef.current) {
453
+ level1MockupRef.current = newLevel1;
454
+ setLevel1MockupId(newLevel1);
455
+ level1SubscribersRef.current.forEach((cb) => cb(newLevel1));
456
+ }
457
+
458
+ // Only notify subscribers for mockups whose priority actually changed
459
+ changedMockups.forEach(({ mockupId, newLevel }) => {
460
+ const subscribers = prioritySubscribersRef.current.get(mockupId);
461
+ subscribers?.forEach((cb) => cb(newLevel));
462
+ });
463
+
464
+ return;
465
+ }
466
+
467
+ // ===== DESKTOP MODE (visibility-based) =====
468
+ const visibilities = [...visibilityMapRef.current.entries()];
469
+
470
+ if (visibilities.length === 0) {
471
+ return;
472
+ }
473
+
474
+ // Find the mockup with the largest visible area
475
+ let maxVisibleArea = 0;
476
+ let level1Candidate: string | null = null;
477
+
478
+ visibilities.forEach(([mockupId, info]) => {
479
+ if (info.visibleArea > maxVisibleArea) {
480
+ maxVisibleArea = info.visibleArea;
481
+ level1Candidate = mockupId;
482
+ }
483
+ });
484
+
485
+ // Assign levels based on visibility and track changes
486
+ const newLevel2: string[] = [];
487
+ const newLevel3: string[] = [];
488
+ const changedMockups: Array<{ mockupId: string; newLevel: PriorityLevel }> = [];
489
+
490
+ visibilities.forEach(([mockupId, info]) => {
491
+ const oldLevel = prioritiesRef.current.get(mockupId);
492
+ let newLevel: PriorityLevel;
493
+
494
+ if (mockupId === level1Candidate) {
495
+ newLevel = 1;
496
+ } else if (info.visibleArea > 0) {
497
+ // Actually visible in viewport
498
+ newLevel = 2;
499
+ newLevel2.push(mockupId);
500
+ } else if (info.isInPreloadZone) {
501
+ // Within preload margin - promote to Level 2 to start loading before visible
502
+ newLevel = 2;
503
+ newLevel2.push(mockupId);
504
+ } else {
505
+ newLevel = 3;
506
+ newLevel3.push(mockupId);
507
+ }
508
+
509
+ prioritiesRef.current.set(mockupId, newLevel);
510
+
511
+ // Track if priority changed
512
+ if (oldLevel !== newLevel) {
513
+ changedMockups.push({ mockupId, newLevel });
514
+ }
515
+ });
516
+
517
+ level2MockupsRef.current = newLevel2;
518
+ level3QueueRef.current = newLevel3.slice(0, MAX_LEVEL3_PREFETCH);
519
+
520
+ // Update Level 1 if changed
521
+ if (level1Candidate !== level1MockupRef.current && (level1Candidate !== null || maxVisibleArea > 0)) {
522
+ level1MockupRef.current = level1Candidate;
523
+ setLevel1MockupId(level1Candidate);
524
+ level1SubscribersRef.current.forEach((cb) => cb(level1Candidate));
525
+ }
526
+
527
+ // Only notify subscribers for mockups whose priority actually changed
528
+ changedMockups.forEach(({ mockupId, newLevel }) => {
529
+ const subscribers = prioritySubscribersRef.current.get(mockupId);
530
+ subscribers?.forEach((cb) => cb(newLevel));
531
+ });
532
+ }, []);
533
+
534
+ /**
535
+ * Debounced priority recalculation
536
+ */
537
+ const scheduleRecalculation = useCallback(() => {
538
+ if (recalcTimeoutRef.current) {
539
+ clearTimeout(recalcTimeoutRef.current);
540
+ }
541
+ recalcTimeoutRef.current = setTimeout(() => {
542
+ recalculatePriorities();
543
+ recalcTimeoutRef.current = null;
544
+ }, PRIORITY_RECALC_DEBOUNCE_MS);
545
+ }, [recalculatePriorities]);
546
+
547
+ // Cleanup on unmount
548
+ useEffect(() => {
549
+ return () => {
550
+ if (recalcTimeoutRef.current) {
551
+ clearTimeout(recalcTimeoutRef.current);
552
+ }
553
+ if (levelAdvanceTimeoutRef.current) {
554
+ clearTimeout(levelAdvanceTimeoutRef.current);
555
+ }
556
+ if (pendingBlobRequestTimeoutRef.current) {
557
+ clearTimeout(pendingBlobRequestTimeoutRef.current);
558
+ }
559
+ if (pendingRafRef.current !== null) {
560
+ cancelAnimationFrame(pendingRafRef.current);
561
+ }
562
+ };
563
+ }, []);
564
+
565
+ /**
566
+ * Update visibility data for all mockups from their element refs.
567
+ * Called on scroll to keep visibility data fresh.
568
+ */
569
+ const updateVisibilityFromRefs = useCallback(() => {
570
+ const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
571
+ const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
572
+ const viewportCenterY = viewportHeight / 2;
573
+
574
+ const elementCount = elementRefsRef.current.size;
575
+ if (elementCount === 0) {
576
+ return;
577
+ }
578
+
579
+ elementRefsRef.current.forEach((element, mockupId) => {
580
+ const existing = visibilityMapRef.current.get(mockupId);
581
+ if (!existing) {
582
+ return;
583
+ }
584
+
585
+ const rect = element.getBoundingClientRect();
586
+
587
+ // Skip hidden elements (display: none returns all zeros)
588
+ // This handles responsive layouts where only one version is visible
589
+ if (rect.height === 0 || rect.width === 0) {
590
+ return;
591
+ }
592
+
593
+ const elementCenterY = rect.top + rect.height / 2;
594
+ const distanceFromCenter = Math.abs(viewportCenterY - elementCenterY);
595
+
596
+ // Calculate visible area
597
+ const visibleTop = Math.max(0, rect.top);
598
+ const visibleBottom = Math.min(viewportHeight, rect.bottom);
599
+ const visibleLeft = Math.max(0, rect.left);
600
+ const visibleRight = Math.min(viewportWidth, rect.right);
601
+ const visibleWidth = Math.max(0, visibleRight - visibleLeft);
602
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
603
+ const visibleArea = visibleWidth * visibleHeight;
604
+
605
+ // Calculate intersection ratio
606
+ const totalArea = rect.width * rect.height;
607
+ const intersectionRatio = totalArea > 0 ? visibleArea / totalArea : 0;
608
+
609
+ // Calculate if element is in preload zone (within PRELOAD_MARGIN_PX of viewport)
610
+ const isInPreloadZone =
611
+ rect.bottom > -PRELOAD_MARGIN_PX &&
612
+ rect.top < viewportHeight + PRELOAD_MARGIN_PX;
613
+
614
+ // Update visibility info
615
+ visibilityMapRef.current.set(mockupId, {
616
+ ...existing,
617
+ intersectionRatio,
618
+ visibleArea,
619
+ boundingRect: rect,
620
+ distanceFromCenter,
621
+ isAboveViewport: rect.bottom < 0,
622
+ isInPreloadZone,
623
+ lastUpdated: Date.now(),
624
+ });
625
+ });
626
+ }, []);
627
+
628
+ // Visibility polling for priority updates
629
+ // Uses polling instead of scroll events for reliable cross-platform behavior
630
+ // (iOS momentum scrolling doesn't fire scroll events reliably)
631
+ useEffect(() => {
632
+
633
+ const pollVisibility = () => {
634
+ updateVisibilityFromRefs();
635
+ recalculatePriorities();
636
+ };
637
+
638
+ // Poll every 100ms for responsive updates on all platforms
639
+ const pollInterval = setInterval(pollVisibility, 100);
640
+
641
+ // Also listen to scroll for immediate response on desktop
642
+ // NOTE: Removed resize listener - it causes flash on Safari iPad when address bar
643
+ // shows/hides (triggers state updates). The 100ms poll handles resize cases adequately.
644
+ window.addEventListener('scroll', pollVisibility, { passive: true });
645
+
646
+ // Initial calculation
647
+ pollVisibility();
648
+
649
+ return () => {
650
+ clearInterval(pollInterval);
651
+ window.removeEventListener('scroll', pollVisibility);
652
+ };
653
+ }, [updateVisibilityFromRefs, recalculatePriorities]);
654
+
655
+ // --- Context Methods ---
656
+
657
+ const registerMockup = useCallback(
658
+ (mockupId: string, placement: string, initialPriority?: PriorityLevel, elementRef?: HTMLElement | null) => {
659
+
660
+ // Track registration order (if not already registered)
661
+ if (!registrationOrderRef.current.includes(mockupId)) {
662
+ registrationOrderRef.current.push(mockupId);
663
+ }
664
+
665
+ // Store element ref if provided
666
+ if (elementRef) {
667
+ elementRefsRef.current.set(mockupId, elementRef);
668
+ }
669
+
670
+ // Initialize with default values
671
+ visibilityMapRef.current.set(mockupId, {
672
+ mockupId,
673
+ placement,
674
+ intersectionRatio: 0,
675
+ visibleArea: 0,
676
+ boundingRect: null,
677
+ distanceFromCenter: Infinity,
678
+ isAboveViewport: true,
679
+ isInPreloadZone: false,
680
+ lastUpdated: Date.now(),
681
+ });
682
+
683
+ // Use initialPriority if provided, otherwise fall back to existing logic
684
+ if (initialPriority !== undefined) {
685
+ prioritiesRef.current.set(mockupId, initialPriority);
686
+
687
+ // Update level refs based on initial priority
688
+ if (initialPriority === 1 && !level1MockupRef.current) {
689
+ level1MockupRef.current = mockupId;
690
+ setLevel1MockupId(mockupId);
691
+ } else if (initialPriority === 2) {
692
+ if (!level2MockupsRef.current.includes(mockupId)) {
693
+ level2MockupsRef.current.push(mockupId);
694
+ }
695
+ } else {
696
+ // Level 3
697
+ if (!level3QueueRef.current.includes(mockupId)) {
698
+ level3QueueRef.current.push(mockupId);
699
+ }
700
+ }
701
+
702
+ // Notify subscribers of initial priority
703
+ const callbacks = prioritySubscribersRef.current.get(mockupId);
704
+ if (callbacks) {
705
+ callbacks.forEach((callback) => callback(initialPriority));
706
+ }
707
+ } else {
708
+ // Fallback: Level 3 until visibility is reported
709
+ prioritiesRef.current.set(mockupId, 3);
710
+
711
+ // If this is the first mockup, assume it might be visible (above the fold)
712
+ if (prioritiesRef.current.size === 1 && !level1MockupRef.current) {
713
+ prioritiesRef.current.set(mockupId, 1);
714
+ level1MockupRef.current = mockupId;
715
+ setLevel1MockupId(mockupId);
716
+ } else {
717
+ }
718
+ }
719
+ },
720
+ []
721
+ );
722
+
723
+ const unregisterMockup = useCallback((mockupId: string) => {
724
+ visibilityMapRef.current.delete(mockupId);
725
+ prioritiesRef.current.delete(mockupId);
726
+ prioritySubscribersRef.current.delete(mockupId);
727
+ elementRefsRef.current.delete(mockupId);
728
+
729
+ // Remove from registration order
730
+ const orderIndex = registrationOrderRef.current.indexOf(mockupId);
731
+ if (orderIndex !== -1) {
732
+ registrationOrderRef.current.splice(orderIndex, 1);
733
+ }
734
+
735
+ // If this was Level 1, recalculate
736
+ if (level1MockupRef.current === mockupId) {
737
+ level1MockupRef.current = null;
738
+ setLevel1MockupId(null);
739
+ // Schedule recalculation to find new Level 1
740
+ scheduleRecalculation();
741
+ }
742
+ }, [scheduleRecalculation]);
743
+
744
+ // Update element ref (if element mounts after registration)
745
+ const updateElementRef = useCallback((mockupId: string, element: HTMLElement | null) => {
746
+ if (element) {
747
+ // Only store refs for visible elements (skip display:none)
748
+ // This handles responsive layouts with multiple HeroProductImage per mockupId
749
+ const rect = element.getBoundingClientRect();
750
+ if (rect.height === 0 || rect.width === 0) {
751
+ return;
752
+ }
753
+
754
+ elementRefsRef.current.set(mockupId, element);
755
+ // Trigger visibility update AND priority recalculation
756
+ updateVisibilityFromRefs();
757
+ scheduleRecalculation();
758
+ } else {
759
+ elementRefsRef.current.delete(mockupId);
760
+ }
761
+ }, [updateVisibilityFromRefs, scheduleRecalculation]);
762
+
763
+ const reportVisibility = useCallback(
764
+ (mockupId: string, ratio: number, boundingRect: DOMRectReadOnly) => {
765
+ const existing = visibilityMapRef.current.get(mockupId);
766
+ if (!existing) {
767
+ return;
768
+ }
769
+
770
+ const viewportHeight =
771
+ typeof window !== 'undefined' ? window.innerHeight : 800;
772
+ const viewportCenterY = viewportHeight / 2;
773
+ const elementCenterY = boundingRect.top + boundingRect.height / 2;
774
+ const distanceFromCenter = Math.abs(viewportCenterY - elementCenterY);
775
+
776
+ // Calculate visible area
777
+ const visibleTop = Math.max(0, boundingRect.top);
778
+ const visibleBottom = Math.min(viewportHeight, boundingRect.bottom);
779
+ const visibleLeft = Math.max(0, boundingRect.left);
780
+ const visibleRight = Math.min(
781
+ typeof window !== 'undefined' ? window.innerWidth : 1200,
782
+ boundingRect.right
783
+ );
784
+ const visibleWidth = Math.max(0, visibleRight - visibleLeft);
785
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
786
+ const visibleArea = visibleWidth * visibleHeight;
787
+
788
+ // Check if this is the first real visibility report for a visible mockup
789
+ // (transition from no data to visible)
790
+ const isFirstVisibleReport = existing.intersectionRatio === 0 && ratio > 0;
791
+
792
+ // Calculate if element is in preload zone (within PRELOAD_MARGIN_PX of viewport)
793
+ const isInPreloadZone =
794
+ boundingRect.bottom > -PRELOAD_MARGIN_PX &&
795
+ boundingRect.top < viewportHeight + PRELOAD_MARGIN_PX;
796
+
797
+ // Update visibility info
798
+ visibilityMapRef.current.set(mockupId, {
799
+ ...existing,
800
+ intersectionRatio: ratio,
801
+ visibleArea,
802
+ boundingRect,
803
+ distanceFromCenter,
804
+ isAboveViewport: boundingRect.bottom < 0,
805
+ isInPreloadZone,
806
+ lastUpdated: Date.now(),
807
+ });
808
+
809
+ // If this is the first visibility report for a visible mockup,
810
+ // trigger immediate recalculation to fix initial priorities quickly
811
+ if (isFirstVisibleReport) {
812
+ // Cancel any pending debounced recalculation
813
+ if (recalcTimeoutRef.current) {
814
+ clearTimeout(recalcTimeoutRef.current);
815
+ recalcTimeoutRef.current = null;
816
+ }
817
+ // Use requestAnimationFrame to batch multiple first-visible reports in the same frame
818
+ // Only schedule one rAF callback
819
+ if (pendingRafRef.current === null) {
820
+ pendingRafRef.current = requestAnimationFrame(() => {
821
+ pendingRafRef.current = null;
822
+ recalculatePriorities();
823
+ });
824
+ }
825
+ } else {
826
+ // Normal scroll: use debounced recalculation
827
+ scheduleRecalculation();
828
+ }
829
+ },
830
+ [scheduleRecalculation, recalculatePriorities]
831
+ );
832
+
833
+ // Mobile carousel mode - uses simple index-based priority
834
+ const reportMobileCarouselIndex = useCallback(
835
+ (currentIndex: number, mockupIds: string[]) => {
836
+ mobileCarouselRef.current = { currentIndex, mockupIds };
837
+ // Immediate recalculation with index-based priority
838
+ recalculatePriorities();
839
+ },
840
+ [recalculatePriorities]
841
+ );
842
+
843
+ const clearMobileCarouselMode = useCallback(() => {
844
+ mobileCarouselRef.current = null;
845
+ // Switch to visibility-based priority
846
+ recalculatePriorities();
847
+ }, [recalculatePriorities]);
848
+
849
+ const getPriorityLevel = useCallback((mockupId: string): PriorityLevel => {
850
+ return prioritiesRef.current.get(mockupId) || 3;
851
+ }, []);
852
+
853
+ const getQueuePriority = useCallback((mockupId: string): number => {
854
+ const level = prioritiesRef.current.get(mockupId) || 3;
855
+ switch (level) {
856
+ case 1: return 100;
857
+ case 2: return 50;
858
+ case 3: return 10;
859
+ default: return 10;
860
+ }
861
+ }, []);
862
+
863
+ const getLevel1Mockup = useCallback((): string | null => {
864
+ return level1MockupRef.current;
865
+ }, []);
866
+
867
+ const getLevel2Mockups = useCallback((): string[] => {
868
+ return [...level2MockupsRef.current];
869
+ }, []);
870
+
871
+ const getLevel3Queue = useCallback((): string[] => {
872
+ return [...level3QueueRef.current];
873
+ }, []);
874
+
875
+ const getVisibility = useCallback((mockupId: string): VisibilityInfo | null => {
876
+ return visibilityMapRef.current.get(mockupId) || null;
877
+ }, []);
878
+
879
+ const getMockupIdsForLevel = useCallback((level: PriorityLevel): string[] => {
880
+ switch (level) {
881
+ case 1:
882
+ return level1MockupRef.current ? [level1MockupRef.current] : [];
883
+ case 2:
884
+ return [...level2MockupsRef.current];
885
+ case 3:
886
+ return [...level3QueueRef.current];
887
+ default:
888
+ return [];
889
+ }
890
+ }, []);
891
+
892
+ const shouldPauseLevel = useCallback((level: 2 | 3): boolean => {
893
+ // Level 2 is paused when Level 1 is active
894
+ // Level 3 is paused when Level 1 or Level 2 is active
895
+ if (level === 2) {
896
+ return level1MockupRef.current !== null;
897
+ }
898
+ // level === 3
899
+ return (
900
+ level1MockupRef.current !== null || level2MockupsRef.current.length > 0
901
+ );
902
+ }, []);
903
+
904
+ const isLevel1Active = useCallback((): boolean => {
905
+ return level1MockupRef.current !== null;
906
+ }, []);
907
+
908
+ const subscribePriorityChange = useCallback(
909
+ (mockupId: string, callback: (level: PriorityLevel) => void) => {
910
+ if (!prioritySubscribersRef.current.has(mockupId)) {
911
+ prioritySubscribersRef.current.set(mockupId, new Set());
912
+ }
913
+ prioritySubscribersRef.current.get(mockupId)!.add(callback);
914
+
915
+ // Immediately call with current priority
916
+ const currentLevel = prioritiesRef.current.get(mockupId) || 3;
917
+ callback(currentLevel);
918
+
919
+ return () => {
920
+ const subscribers = prioritySubscribersRef.current.get(mockupId);
921
+ if (subscribers) {
922
+ subscribers.delete(callback);
923
+ if (subscribers.size === 0) {
924
+ prioritySubscribersRef.current.delete(mockupId);
925
+ }
926
+ }
927
+ };
928
+ },
929
+ []
930
+ );
931
+
932
+ const subscribeLevel1Change = useCallback(
933
+ (callback: (mockupId: string | null) => void) => {
934
+ level1SubscribersRef.current.add(callback);
935
+ // Immediately call with current Level 1
936
+ callback(level1MockupRef.current);
937
+
938
+ return () => {
939
+ level1SubscribersRef.current.delete(callback);
940
+ };
941
+ },
942
+ []
943
+ );
944
+
945
+ // Create stable context value
946
+ const contextValue = useMemo<MockupPriorityContextValue>(
947
+ () => ({
948
+ registerMockup,
949
+ unregisterMockup,
950
+ updateElementRef,
951
+ reportVisibility,
952
+ reportMobileCarouselIndex,
953
+ clearMobileCarouselMode,
954
+ getPriorityLevel,
955
+ getQueuePriority,
956
+ getLevel1Mockup,
957
+ getLevel2Mockups,
958
+ getLevel3Queue,
959
+ getVisibility,
960
+ getMockupIdsForLevel,
961
+ shouldPauseLevel,
962
+ isLevel1Active,
963
+ subscribePriorityChange,
964
+ subscribeLevel1Change,
965
+ }),
966
+ [
967
+ registerMockup,
968
+ unregisterMockup,
969
+ updateElementRef,
970
+ reportVisibility,
971
+ reportMobileCarouselIndex,
972
+ clearMobileCarouselMode,
973
+ getPriorityLevel,
974
+ getQueuePriority,
975
+ getLevel1Mockup,
976
+ getLevel2Mockups,
977
+ getLevel3Queue,
978
+ getVisibility,
979
+ getMockupIdsForLevel,
980
+ shouldPauseLevel,
981
+ isLevel1Active,
982
+ subscribePriorityChange,
983
+ subscribeLevel1Change,
984
+ ]
985
+ );
986
+
987
+ return (
988
+ <MockupPriorityContext.Provider value={contextValue}>
989
+ {children}
990
+ </MockupPriorityContext.Provider>
991
+ );
992
+ }
993
+
994
+ /**
995
+ * Hook to access mockup priority context
996
+ * Throws if not inside MockupPriorityProvider
997
+ */
998
+ export function useMockupPriority(): MockupPriorityContextValue {
999
+ const context = useContext(MockupPriorityContext);
1000
+ if (!context) {
1001
+ throw new Error(
1002
+ 'useMockupPriority must be used within a MockupPriorityProvider'
1003
+ );
1004
+ }
1005
+ return context;
1006
+ }
1007
+
1008
+ /**
1009
+ * Hook to optionally access mockup priority context
1010
+ * Returns undefined if not inside MockupPriorityProvider
1011
+ */
1012
+ export function useMockupPriorityOptional(): MockupPriorityContextValue | undefined {
1013
+ return useContext(MockupPriorityContext);
1014
+ }