@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,1162 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RealtimeProvider - Separate context for realtime mockup state
5
+ *
6
+ * This provider is intentionally SEPARATE from ProductContext to prevent
7
+ * mockup result updates from causing re-renders in components that only
8
+ * care about product selection (options, price, etc.).
9
+ *
10
+ * Architecture:
11
+ * - RealtimeProvider wraps components that need realtime mockup updates
12
+ * - It subscribes to ProductContext for product data but manages its own state
13
+ * - Updates to mockupResults only re-render RealtimeContext consumers
14
+ * - ProductContext consumers are NOT affected by realtime updates
15
+ *
16
+ * Usage:
17
+ * ```tsx
18
+ * <Product productId="...">
19
+ * <ProductOptions /> {/* Not affected by realtime updates *\/}
20
+ * <ProductPrice /> {/* Not affected by realtime updates *\/}
21
+ *
22
+ * <RealtimeProvider>
23
+ * <HeroProductImage /> {/* Receives realtime updates *\/}
24
+ * <CanvasEditor /> {/* Can send canvas blobs *\/}
25
+ * </RealtimeProvider>
26
+ * </Product>
27
+ * ```
28
+ */
29
+
30
+ import React, {
31
+ createContext,
32
+ useContext,
33
+ useState,
34
+ useEffect,
35
+ useCallback,
36
+ useRef,
37
+ useMemo,
38
+ } from "react";
39
+ import { useRealtimeMockup } from "@snowcone-app/sdk/react";
40
+ import { resolveVariantId as resolveVariantIdUtil } from "@snowcone-app/sdk";
41
+ import { readEnv } from "../lib/env";
42
+ import { useProductOptional } from "./Product";
43
+ import type { ReactProductContext } from "./Product";
44
+
45
+ // Default artboard size for export calculations (matches @snowcone-app/canvas default)
46
+ const DEFAULT_ARTBOARD_SIZE = 2000;
47
+
48
+ // CANVAS_EXPORT_THROTTLE_MS removed - throttling now handled by @snowcone-app/canvas
49
+
50
+ // iOS Safari has strict blob URL limits (~50-100 concurrent)
51
+ // Enforce a conservative cap to prevent memory crashes
52
+ const MAX_CONCURRENT_BLOBS = 5;
53
+
54
+ // Realtime mockup types
55
+ export interface MockupResult {
56
+ mockupId: string;
57
+ imageUrl: string;
58
+ }
59
+
60
+ // Type alias for backward compatibility with code that imported RealtimeState from Product
61
+ export type RealtimeState = RealtimeContextValue;
62
+
63
+ export interface RealtimeContextValue {
64
+ // Connection state
65
+ isEnabled: boolean;
66
+ isConnected: boolean;
67
+ isConfigured: boolean;
68
+
69
+ // Mockup results (use subscriptions for performance-critical components)
70
+ mockupResults: MockupResult[];
71
+
72
+ // Debug/tracking state (now refs, won't trigger re-renders)
73
+ isPendingMockups: boolean;
74
+ canvasBlobsSent: number;
75
+ colorBlobsSent: number;
76
+ lastBlobSentTime: string | null;
77
+
78
+ // Configuration
79
+ canvasExportSize: { width: number; height: number };
80
+ mockupWidth: number;
81
+ placementDimensions: Array<{
82
+ label: string;
83
+ width?: number;
84
+ height?: number;
85
+ type: string;
86
+ }>;
87
+
88
+ // Methods
89
+ enableRealtime: () => void;
90
+ disableRealtime: () => void;
91
+ sendCanvasBlob: (
92
+ placement: string,
93
+ blob: Blob,
94
+ mockupCount?: number,
95
+ baseThrottleMs?: number
96
+ ) => boolean;
97
+
98
+ /** Send canvas state JSON for server-side rendering (used by SnowconeCanvas JSON mode) */
99
+ sendCanvasState: (
100
+ placement: string,
101
+ state: object,
102
+ mockupCount?: number,
103
+ baseThrottleMs?: number
104
+ ) => boolean;
105
+
106
+ /**
107
+ * Update mockupIds to render specific mockups using cached blobs.
108
+ * Server will use already-uploaded blobs to render the requested mockups.
109
+ * This is the preferred method for priority-based rendering.
110
+ */
111
+ updateMockupIds: (mockupIds: string[]) => boolean;
112
+
113
+ /**
114
+ * Update placementSettings to override scaleMode, alignment, etc.
115
+ * Used to force scaleMode: "fill" when canvas editor exports images.
116
+ */
117
+ updatePlacementSettings: (settings: Record<string, { scaleMode?: 'fill' | 'fit'; tiles?: number; alignment?: string }>) => boolean;
118
+
119
+ // PERFORMANCE: Direct access without state updates
120
+ getMockupResultsImmediate: () => MockupResult[];
121
+
122
+ // PERFORMANCE: Subscription system for immediate notifications
123
+ subscribeMockupResults: (
124
+ callback: (results: MockupResult[]) => void
125
+ ) => () => void;
126
+
127
+ // PERFORMANCE: Filtered subscription for specific mockupId
128
+ subscribeMockupResultById: (
129
+ mockupId: string,
130
+ callback: (result: MockupResult) => void
131
+ ) => () => void;
132
+
133
+ // PERFORMANCE: Subscribe to pending blob notifications (for shimmer effects)
134
+ subscribePendingBlob: (
135
+ callback: (placement: string) => void
136
+ ) => () => void;
137
+
138
+ /**
139
+ * Notify that an export is starting for a placement.
140
+ * Call this immediately when a canvas change is detected to show shimmer
141
+ * before the export and network request complete.
142
+ */
143
+ notifyPendingExport: (placement: string) => void;
144
+
145
+ /**
146
+ * Notify that a previously-pending export was skipped or cancelled (e.g. the
147
+ * canvas decided the change is a no-op — `skipBlob=true` with no JSON sent).
148
+ * Clears the optimistic pending-state from `notifyPendingExport` so consumers
149
+ * subscribed via `subscribePendingExportCancelled` can drop their loading
150
+ * indicators without waiting for a server result that will never arrive.
151
+ */
152
+ notifyPendingExportCancelled: (placement: string) => void;
153
+
154
+ /**
155
+ * Subscribe to "previously-pending export was cancelled" notifications.
156
+ * Pairs with `subscribePendingBlob` — when an optimistic pending fires but
157
+ * the actual export turns out to be a no-op, this fires so subscribers can
158
+ * roll back their pending UI state.
159
+ */
160
+ subscribePendingExportCancelled: (
161
+ callback: (placement: string) => void
162
+ ) => () => void;
163
+
164
+ /**
165
+ * Subscribe to blob received notifications (when server confirms blob receipt).
166
+ * Use this to trigger mockup requests only after the server has the new blob.
167
+ */
168
+ subscribeBlobReceived: (
169
+ callback: (placement: string) => void
170
+ ) => () => void;
171
+
172
+ /**
173
+ * Subscribe to blob sent notifications (when blob is actually sent after throttle).
174
+ * Use this to trigger mockup requests - more reliable than blob_received for
175
+ * subsequent blobs to the same placement.
176
+ */
177
+ subscribeBlobSent: (
178
+ callback: (placement: string) => void
179
+ ) => () => void;
180
+
181
+ // Flush pending blobs immediately (call on mouse up, action complete, etc.)
182
+ flushPendingBlobs: () => void;
183
+ hasPendingBlobs: () => boolean;
184
+
185
+ subscribePipelineSettled: (callback: () => void) => () => void;
186
+ resetPipelineSettled: () => void;
187
+
188
+
189
+ // ============================================================================
190
+ // CENTRALIZED URL MANAGEMENT (NEW)
191
+ // ============================================================================
192
+
193
+ /**
194
+ * Get the current mockup URL for a given mockupId.
195
+ * Returns the realtime URL if available, null otherwise.
196
+ * Components should use this instead of subscribing directly.
197
+ */
198
+ getMockupUrl: (mockupId: string) => string | null;
199
+
200
+ // WebRTC streaming (Phase 2)
201
+
202
+ // Pipeline timing (debug)
203
+ subscribeRTCTiming: (callback: (timing: any) => void) => () => void;
204
+
205
+ /**
206
+ * Get multiple mockup URLs at once.
207
+ * Returns a record mapping mockupId to URL (or null if not available).
208
+ */
209
+ getMockupUrls: (mockupIds: string[]) => Record<string, string | null>;
210
+
211
+ /**
212
+ * Check if a mockup is currently loading (pending export/render).
213
+ * Use this to show shimmer/loading states.
214
+ */
215
+ isMockupLoading: (mockupId: string) => boolean;
216
+
217
+ /**
218
+ * Register a blob URL with centralized lifecycle management.
219
+ * Creates a blob URL if given a Blob, or stores the URL directly.
220
+ * Enforces MAX_CONCURRENT_BLOBS limit, revoking oldest URL when exceeded.
221
+ * @param blobOrUrl - Blob to create URL from, or existing URL string
222
+ * @param placementName - Optional placement name for tracking
223
+ * @returns The blob URL (created or passed through)
224
+ */
225
+ registerBlobUrl: (blobOrUrl: Blob | string, placementName?: string) => string;
226
+ }
227
+
228
+ const RealtimeContext = createContext<RealtimeContextValue | undefined>(
229
+ undefined
230
+ );
231
+
232
+ export interface RealtimeProviderProps {
233
+ children: React.ReactNode;
234
+ /** WebSocket URL for realtime mockups */
235
+ wsUrl?: string;
236
+ /** Width for realtime mockup generation */
237
+ mockupWidth?: number;
238
+ /** Auto-enable realtime on mount (default: false) */
239
+ autoEnable?: boolean;
240
+ /** The shop (publishable key = shop.id) the session bills to. */
241
+ shop?: string;
242
+ /**
243
+ * Endpoint that issues a realtime session grant (POST { shop } →
244
+ * { token, expiresAt }). When this + shop are set, the session authorizes
245
+ * via a renewable token. Without them, the legacy seal path runs (shim).
246
+ */
247
+ grantUrl?: string;
248
+ }
249
+
250
+ /**
251
+ * RealtimeProvider - Manages realtime mockup WebSocket connection
252
+ *
253
+ * Must be used inside a <Product> provider to access product data.
254
+ */
255
+ export function RealtimeProvider({
256
+ children,
257
+ // Leave undefined when the env var isn't set so useRealtimeMockup's grant-path
258
+ // default (the public endpoint) applies. Pass wsUrl explicitly to self-host.
259
+ wsUrl = readEnv("NEXT_PUBLIC_MERCH_WS_URL"),
260
+ mockupWidth = 1400,
261
+ autoEnable = false,
262
+ shop,
263
+ grantUrl,
264
+ }: RealtimeProviderProps) {
265
+ const productContext = useProductOptional();
266
+
267
+ const [isEnabled, setIsEnabled] = useState(autoEnable);
268
+
269
+ // When a shop + grant endpoint are wired, authorize the session with a
270
+ // renewable token instead of the legacy bypass seal. Stable identity.
271
+ const useSessionGrant = Boolean(shop && grantUrl);
272
+ const getToken = useCallback(async () => {
273
+ const res = await fetch(grantUrl as string, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/json" },
276
+ body: JSON.stringify({ shop }),
277
+ });
278
+ if (!res.ok) {
279
+ throw new Error(`realtime grant failed: ${res.status}`);
280
+ }
281
+ return (await res.json()) as { token: string; expiresAt: number };
282
+ }, [shop, grantUrl]);
283
+
284
+ // Use refs for debug/tracking state to avoid re-renders during drag
285
+ const canvasBlobsSentRef = useRef(0);
286
+ const colorBlobsSentRef = useRef(0);
287
+ const lastBlobSentTimeRef = useRef<string | null>(null);
288
+ const isPendingMockupsRef = useRef(false);
289
+
290
+ // Track if config has been sent
291
+ const configSentRef = useRef(false);
292
+
293
+ // Track placement settings overrides (e.g., scaleMode: "fill" when canvas editor active)
294
+ const placementSettingsOverridesRef = useRef<Record<string, any>>({});
295
+
296
+ // Track last sent variantId to detect when we need to re-send config
297
+ const lastSentVariantIdRef = useRef<string | null>(null);
298
+
299
+ // Track last sent colors to prevent duplicates
300
+ const lastSentColorsRef = useRef<Record<string, string>>({});
301
+
302
+ // Initialize realtime mockup hook
303
+ const realtimeMockup = useRealtimeMockup({
304
+ wsUrl,
305
+ getToken: useSessionGrant ? getToken : undefined,
306
+ onConnected: (sessionId) => {
307
+ },
308
+ onDisconnected: () => {
309
+ },
310
+ onConfigReceived: () => {
311
+ },
312
+ onBlobReceived: (placement) => {
313
+ const subscriberCount = blobReceivedSubscribersRef.current.size;
314
+ // Notify all blob received subscribers (for priority-based mockup requests)
315
+ blobReceivedSubscribersRef.current.forEach((cb) => {
316
+ cb(placement);
317
+ });
318
+ },
319
+ onBlobSent: (placement) => {
320
+ if (blobQueuedGenRef.current === pipelineGenRef.current) {
321
+ hasBlobInFlightRef.current = true;
322
+ }
323
+ blobSentSubscribersRef.current.forEach((cb) => {
324
+ cb(placement);
325
+ });
326
+ },
327
+ onMockupRendered: (result) => {
328
+ },
329
+ onAllMockupsRendered: (results) => {
330
+ },
331
+ onError: (error) => {
332
+ console.error("[RealtimeProvider] Error:", error);
333
+ },
334
+ });
335
+
336
+ // RTC timing subscription (legacy — kept for API compatibility)
337
+ const subscribeRTCTiming = useCallback((_callback: (timing: any) => void) => {
338
+ return () => {}; // No-op since WebRTC was removed
339
+ }, []);
340
+
341
+ const {
342
+ isConnected,
343
+ isConfigured,
344
+ mockupResults: rawMockupResults,
345
+ sendCanvasBlob: sendCanvasBlobRaw,
346
+ sendCanvasState: sendCanvasStateRaw,
347
+ sendColorBlob,
348
+ sendConfig: sendConfigRaw,
349
+ updateMockupIds: updateMockupIdsRaw,
350
+ updatePlacementSettings: updatePlacementSettingsRaw,
351
+ connect,
352
+ disconnect,
353
+ flushPendingBlobs,
354
+ hasPendingBlobs,
355
+ } = realtimeMockup;
356
+
357
+ // PERFORMANCE: Store mockup results in ref only - NO STATE UPDATES
358
+ // This eliminates re-render cascades entirely. Components that need mockup results
359
+ // should use subscribeMockupResults() or subscribeMockupResultById() for immediate updates.
360
+ // The mockupResults on context is provided for backward compat but reads from ref.
361
+ const mockupResultsRef = useRef<MockupResult[]>([]);
362
+
363
+ // Subscription system for immediate mockup notifications
364
+ const mockupSubscribersRef = useRef<Set<(results: MockupResult[]) => void>>(
365
+ new Set()
366
+ );
367
+
368
+ // Filtered subscription system by mockupId
369
+ const mockupSubscribersByIdRef = useRef<
370
+ Map<string, Set<(result: MockupResult) => void>>
371
+ >(new Map());
372
+
373
+ // Subscription system for pending blob notifications (for shimmer effects)
374
+ const pendingExportCancelledSubscribersRef = useRef<
375
+ Set<(placement: string) => void>
376
+ >(new Set());
377
+
378
+ const pendingBlobSubscribersRef = useRef<Set<(placement: string) => void>>(
379
+ new Set()
380
+ );
381
+
382
+ // Subscription system for blob received notifications (when server confirms receipt)
383
+ const blobReceivedSubscribersRef = useRef<Set<(placement: string) => void>>(
384
+ new Set()
385
+ );
386
+
387
+ // Subscription system for blob sent notifications (when blob is actually sent after throttle)
388
+ const blobSentSubscribersRef = useRef<Set<(placement: string) => void>>(
389
+ new Set()
390
+ );
391
+
392
+
393
+ // ============================================================================
394
+ // CENTRALIZED URL MANAGEMENT (NEW)
395
+ // ============================================================================
396
+
397
+ // Pipeline-settled: fires when no pending blobs AND mockup results arrived since last send.
398
+ const pipelineSettledSubscribersRef = useRef<Set<() => void>>(new Set());
399
+ const pipelineGenRef = useRef(0);
400
+ const blobQueuedGenRef = useRef(0);
401
+ const hasBlobInFlightRef = useRef(false);
402
+ const pipelineResetCooldownRef = useRef(false);
403
+
404
+ // Centralized mockup URL cache - stores URLs by mockupId
405
+ // This is the single source of truth for all mockup URLs (realtime or static)
406
+ const mockupUrlCacheRef = useRef<Map<string, {
407
+ url: string;
408
+ isBlob: boolean;
409
+ timestamp: number;
410
+ }>>(new Map());
411
+
412
+ // Track all registered blob URLs for memory management
413
+ // Uses insertion order to determine oldest URL for eviction
414
+ const registeredBlobUrlsRef = useRef<Set<string>>(new Set());
415
+
416
+ // Track which placements have pending exports (for shimmer state)
417
+ const pendingPlacementsRef = useRef<Set<string>>(new Set());
418
+
419
+ // Track which mockupIds are pending (derived from placements)
420
+ const pendingMockupIdsRef = useRef<Set<string>>(new Set());
421
+
422
+ // Sync rawMockupResults to ref, update URL cache, and notify subscribers (NO STATE UPDATES)
423
+ useEffect(() => {
424
+ if (rawMockupResults.length === 0) {
425
+ if (mockupResultsRef.current.length > 0) {
426
+ mockupResultsRef.current = [];
427
+ mockupSubscribersRef.current.forEach((cb) => cb([]));
428
+ }
429
+ return;
430
+ }
431
+
432
+ console.log(`[RTP] rawMockupResults changed: ${rawMockupResults.length} result(s)`, rawMockupResults.map(r => `${r.mockupId}${r.imageUrl ? ' ✓url' : ' ✗NO-URL'}`));
433
+
434
+ // Update ref immediately
435
+ mockupResultsRef.current = rawMockupResults;
436
+
437
+ // Update centralized URL cache with new results
438
+ const now = Date.now();
439
+ rawMockupResults.forEach((result) => {
440
+ if (result.imageUrl) {
441
+ const wasPending = pendingMockupIdsRef.current.has(result.mockupId);
442
+ mockupUrlCacheRef.current.set(result.mockupId, {
443
+ url: result.imageUrl,
444
+ isBlob: false, // Server URLs are not blob URLs
445
+ timestamp: now,
446
+ });
447
+
448
+ // Clear pending state for this mockup
449
+ pendingMockupIdsRef.current.delete(result.mockupId);
450
+ if (wasPending) {
451
+ console.log(`[RTP] Cleared pending for ${result.mockupId} — url cached`);
452
+ }
453
+ } else {
454
+ console.log(`[RTP] Result for ${result.mockupId} has NO imageUrl — not caching, pending NOT cleared`);
455
+ }
456
+ });
457
+
458
+ // Clear pending placement state so notifyPendingExport can re-fire on next edit.
459
+ // We clear based on result placements when available, otherwise clear all.
460
+ const clearedPlacements = new Set<string>();
461
+ rawMockupResults.forEach((result) => {
462
+ if (result.placement) clearedPlacements.add(result.placement);
463
+ });
464
+ if (clearedPlacements.size > 0) {
465
+ clearedPlacements.forEach((p) => pendingPlacementsRef.current.delete(p));
466
+ } else {
467
+ pendingPlacementsRef.current.clear();
468
+ }
469
+
470
+ // Notify subscribers immediately (no throttle needed - no state updates!)
471
+ mockupSubscribersRef.current.forEach((cb) => cb(rawMockupResults));
472
+
473
+ // Notify filtered subscribers
474
+ rawMockupResults.forEach((result) => {
475
+ const subscribers = mockupSubscribersByIdRef.current.get(result.mockupId);
476
+ if (subscribers && subscribers.size > 0) {
477
+ console.log(`[RTP] Notifying ${subscribers.size} subscriber(s) for ${result.mockupId}`);
478
+ subscribers.forEach((cb) => cb(result));
479
+ } else {
480
+ console.log(`[RTP] No subscribers for ${result.mockupId} — result received but nobody listening`);
481
+ }
482
+ });
483
+
484
+ // Pipeline-settled check
485
+ if (hasBlobInFlightRef.current && !hasPendingBlobs() && !pipelineResetCooldownRef.current) {
486
+ hasBlobInFlightRef.current = false;
487
+ pipelineSettledSubscribersRef.current.forEach((cb) => cb());
488
+ }
489
+ }, [rawMockupResults]);
490
+
491
+ // Wrap sendCanvasBlob to track when blobs are sent
492
+ const sendCanvasBlob = useCallback(
493
+ (
494
+ placement: string,
495
+ blob: Blob,
496
+ mockupCount?: number,
497
+ baseThrottleMs?: number
498
+ ) => {
499
+ blobQueuedGenRef.current = pipelineGenRef.current;
500
+ const result = sendCanvasBlobRaw(
501
+ placement,
502
+ blob,
503
+ mockupCount,
504
+ baseThrottleMs
505
+ );
506
+ if (result) {
507
+ canvasBlobsSentRef.current += 1;
508
+ lastBlobSentTimeRef.current = new Date().toLocaleTimeString();
509
+ isPendingMockupsRef.current = true;
510
+ // Notify pending blob subscribers (for shimmer effects)
511
+ pendingBlobSubscribersRef.current.forEach((cb) => cb(placement));
512
+ }
513
+ return result;
514
+ },
515
+ [sendCanvasBlobRaw]
516
+ );
517
+
518
+ // Wrap sendCanvasState for JSON mode (server-side canvas rendering)
519
+ const sendCanvasState = useCallback(
520
+ (
521
+ placement: string,
522
+ state: object,
523
+ mockupCount?: number,
524
+ baseThrottleMs?: number
525
+ ) => {
526
+ blobQueuedGenRef.current = pipelineGenRef.current;
527
+ const result = sendCanvasStateRaw(
528
+ placement,
529
+ state,
530
+ mockupCount,
531
+ baseThrottleMs
532
+ );
533
+ if (result) {
534
+ canvasBlobsSentRef.current += 1;
535
+ lastBlobSentTimeRef.current = new Date().toLocaleTimeString();
536
+ isPendingMockupsRef.current = true;
537
+ pendingBlobSubscribersRef.current.forEach((cb) => cb(placement));
538
+ }
539
+ return result;
540
+ },
541
+ [sendCanvasStateRaw]
542
+ );
543
+
544
+ // Wrap sendColorBlob to track when colors are sent
545
+ const sendColorBlobTracked = useCallback(
546
+ (placement: string, color: string) => {
547
+ const result = sendColorBlob(placement, color);
548
+ if (result) {
549
+ colorBlobsSentRef.current += 1;
550
+ lastBlobSentTimeRef.current = new Date().toLocaleTimeString();
551
+ isPendingMockupsRef.current = true;
552
+ }
553
+ return result;
554
+ },
555
+ [sendColorBlob]
556
+ );
557
+
558
+ // Connect when enabled
559
+ useEffect(() => {
560
+ if (isEnabled) {
561
+ connect();
562
+
563
+ return () => {
564
+ disconnect();
565
+ configSentRef.current = false;
566
+ lastSentVariantIdRef.current = null;
567
+ lastSentColorsRef.current = {};
568
+ };
569
+ }
570
+ }, [isEnabled, connect, disconnect, wsUrl]);
571
+
572
+ // Clear pending state when mockup results arrive
573
+ useEffect(() => {
574
+ if (rawMockupResults.length > 0 && isPendingMockupsRef.current) {
575
+ isPendingMockupsRef.current = false;
576
+ }
577
+ }, [rawMockupResults.length]);
578
+
579
+ // Auto-send config when connected and product is available
580
+ // Also re-send when variantId changes (e.g., user selects different product options)
581
+ useEffect(() => {
582
+ if (!isEnabled || !isConnected || !productContext?.product) return;
583
+
584
+ const product = productContext.product;
585
+ const productId = product.id;
586
+ const placements = product.placements || [];
587
+
588
+ // Resolve variant ID based on current selection
589
+ const variantId = resolveVariantIdUtil(
590
+ productContext,
591
+ undefined,
592
+ undefined
593
+ );
594
+
595
+ // Check if we need to send config:
596
+ // 1. First time (configSentRef.current is false)
597
+ // 2. VariantId changed (user selected different options)
598
+ const isFirstSend = !configSentRef.current;
599
+ const variantChanged = lastSentVariantIdRef.current !== null &&
600
+ lastSentVariantIdRef.current !== variantId;
601
+
602
+ if (!isFirstSend && !variantChanged) {
603
+ return; // Nothing changed, skip
604
+ }
605
+
606
+ // Get mockup IDs from product.mockups
607
+ const mockupIds: string[] = [];
608
+ if (product.mockups && Array.isArray(product.mockups)) {
609
+ product.mockups.forEach((mockup: any) => {
610
+ if (mockup.id) {
611
+ mockupIds.push(mockup.id);
612
+ }
613
+ });
614
+ }
615
+
616
+ // Authed sessions: the grant token (verified on connect) authorizes the
617
+ // session and the server self-signs the render, so no seal is sent and the
618
+ // server bills to the token's shop. Otherwise fall back to the legacy
619
+ // bypass seal + demo shop (shim) so existing setups keep working.
620
+ const configPayload = useSessionGrant
621
+ ? {
622
+ productId,
623
+ mockupIds,
624
+ variantId,
625
+ shop: shop as string,
626
+ width: mockupWidth,
627
+ placementSettings: { ...placementSettingsOverridesRef.current },
628
+ }
629
+ : {
630
+ productId,
631
+ mockupIds,
632
+ variantId,
633
+ shop: "hTidnpNnNa",
634
+ seal: "bypass-sig-for-k6-load-test",
635
+ width: mockupWidth,
636
+ placementSettings: { ...placementSettingsOverridesRef.current },
637
+ };
638
+
639
+ // If variant changed, trigger shimmer on all image placements
640
+ // This provides immediate visual feedback while waiting for new mockups
641
+ if (variantChanged) {
642
+ const imagePlacements = placements.filter((p: any) => p.type === "image");
643
+ imagePlacements.forEach((p: any) => {
644
+ pendingBlobSubscribersRef.current.forEach((cb) => cb(p.label));
645
+ });
646
+ }
647
+
648
+ sendConfigRaw(configPayload);
649
+
650
+ configSentRef.current = true;
651
+ lastSentVariantIdRef.current = variantId;
652
+
653
+ // Send default colors for color placements
654
+ setTimeout(() => {
655
+ const colorPlacements = placements.filter((p: any) => p.type === "color");
656
+ colorPlacements.forEach((p: any) => {
657
+ const defaultColor = productContext?.selection?.[p.label] || "#000000";
658
+ if (lastSentColorsRef.current[p.label] !== defaultColor) {
659
+ sendColorBlobTracked(p.label, defaultColor);
660
+ lastSentColorsRef.current[p.label] = defaultColor;
661
+ }
662
+ });
663
+ }, 600);
664
+ }, [
665
+ isEnabled,
666
+ isConnected,
667
+ productContext?.product,
668
+ productContext?.selection,
669
+ sendConfigRaw,
670
+ sendColorBlobTracked,
671
+ mockupWidth,
672
+ ]);
673
+
674
+ // Watch for color placement changes
675
+ useEffect(() => {
676
+ if (!isEnabled || !isConnected || !isConfigured || !productContext?.product)
677
+ return;
678
+
679
+ const placements = productContext.product.placements || [];
680
+ const colorPlacements = placements.filter((p: any) => p.type === "color");
681
+
682
+ const debounceTimeout = setTimeout(() => {
683
+ colorPlacements.forEach((p: any) => {
684
+ const selectedColor = productContext?.selection?.[p.label];
685
+ if (selectedColor && typeof selectedColor === "string") {
686
+ if (lastSentColorsRef.current[p.label] !== selectedColor) {
687
+ sendColorBlobTracked(p.label, selectedColor);
688
+ lastSentColorsRef.current[p.label] = selectedColor;
689
+ }
690
+ }
691
+ });
692
+ }, 500);
693
+
694
+ return () => clearTimeout(debounceTimeout);
695
+ }, [
696
+ isEnabled,
697
+ isConnected,
698
+ isConfigured,
699
+ productContext?.product,
700
+ productContext?.selection,
701
+ sendColorBlobTracked,
702
+ ]);
703
+
704
+ // Canvas integration uses @snowcone-app/canvas with sendCanvasBlob directly
705
+
706
+ // Wrap updatePlacementSettings to also track in ref (for config re-sends)
707
+ const updatePlacementSettingsWrapped = useCallback(
708
+ (settings: Record<string, any>) => {
709
+ placementSettingsOverridesRef.current = settings;
710
+ return updatePlacementSettingsRaw(settings);
711
+ },
712
+ [updatePlacementSettingsRaw]
713
+ );
714
+
715
+ const enableRealtime = useCallback(() => {
716
+ setIsEnabled(true);
717
+ }, []);
718
+
719
+ const disableRealtime = useCallback(() => {
720
+ setIsEnabled(false);
721
+ }, []);
722
+
723
+ // Calculate placement dimensions from product
724
+ const placementDimensions = useMemo(() => {
725
+ if (!productContext?.product?.placements) return [];
726
+ return productContext.product.placements.map((p: any) => ({
727
+ label: p.label,
728
+ width: p.width,
729
+ height: p.height,
730
+ type: p.type || "image",
731
+ }));
732
+ }, [productContext?.product?.placements]);
733
+
734
+ // Get canvas export size
735
+ const canvasExportSize = useMemo(() => {
736
+ const firstPlacement = productContext?.product?.placements?.[0];
737
+ if (firstPlacement?.width && firstPlacement?.height) {
738
+ return { width: firstPlacement.width, height: firstPlacement.height };
739
+ }
740
+
741
+ return { width: DEFAULT_ARTBOARD_SIZE, height: DEFAULT_ARTBOARD_SIZE };
742
+ }, [productContext?.product?.placements]);
743
+
744
+ // Subscribe functions
745
+ const subscribeMockupResults = useCallback(
746
+ (callback: (results: MockupResult[]) => void) => {
747
+ mockupSubscribersRef.current.add(callback);
748
+ return () => {
749
+ mockupSubscribersRef.current.delete(callback);
750
+ };
751
+ },
752
+ []
753
+ );
754
+
755
+ const subscribeMockupResultById = useCallback(
756
+ (mockupId: string, callback: (result: MockupResult) => void) => {
757
+ if (!mockupSubscribersByIdRef.current.has(mockupId)) {
758
+ mockupSubscribersByIdRef.current.set(mockupId, new Set());
759
+ }
760
+ mockupSubscribersByIdRef.current.get(mockupId)!.add(callback);
761
+ const subscriberCount = mockupSubscribersByIdRef.current.get(mockupId)!.size;
762
+ console.log(`[RTP] subscribeMockupResultById("${mockupId}"): now ${subscriberCount} subscriber(s)`);
763
+
764
+ // Check current results immediately
765
+ const currentResult = mockupResultsRef.current.find(
766
+ (r) => r.mockupId === mockupId
767
+ );
768
+ if (currentResult) {
769
+ console.log(`[RTP] subscribeMockupResultById("${mockupId}"): replaying cached result (hasUrl=${!!currentResult.imageUrl})`);
770
+ callback(currentResult);
771
+ }
772
+
773
+ return () => {
774
+ const subscribers = mockupSubscribersByIdRef.current.get(mockupId);
775
+ if (subscribers) {
776
+ subscribers.delete(callback);
777
+ if (subscribers.size === 0) {
778
+ mockupSubscribersByIdRef.current.delete(mockupId);
779
+ }
780
+ }
781
+ };
782
+ },
783
+ []
784
+ );
785
+
786
+ // Subscribe to pending blob notifications (for shimmer effects)
787
+ const subscribePendingBlob = useCallback(
788
+ (callback: (placement: string) => void) => {
789
+ pendingBlobSubscribersRef.current.add(callback);
790
+ return () => {
791
+ pendingBlobSubscribersRef.current.delete(callback);
792
+ };
793
+ },
794
+ []
795
+ );
796
+
797
+ // Subscribe to pending-export-cancelled notifications. Fires when an
798
+ // optimistic `notifyPendingExport` turns out to have been wrong (the actual
799
+ // export was a no-op).
800
+ const subscribePendingExportCancelled = useCallback(
801
+ (callback: (placement: string) => void) => {
802
+ pendingExportCancelledSubscribersRef.current.add(callback);
803
+ return () => {
804
+ pendingExportCancelledSubscribersRef.current.delete(callback);
805
+ };
806
+ },
807
+ []
808
+ );
809
+
810
+ // Subscribe to blob received notifications (when server confirms receipt)
811
+ const subscribeBlobReceived = useCallback(
812
+ (callback: (placement: string) => void) => {
813
+ blobReceivedSubscribersRef.current.add(callback);
814
+ return () => {
815
+ blobReceivedSubscribersRef.current.delete(callback);
816
+ };
817
+ },
818
+ []
819
+ );
820
+
821
+ // Subscribe to blob sent notifications (when blob is actually sent after throttle)
822
+ const subscribeBlobSent = useCallback(
823
+ (callback: (placement: string) => void) => {
824
+ blobSentSubscribersRef.current.add(callback);
825
+ return () => {
826
+ blobSentSubscribersRef.current.delete(callback);
827
+ };
828
+ },
829
+ []
830
+ );
831
+
832
+ const subscribePipelineSettled = useCallback(
833
+ (callback: () => void) => {
834
+ pipelineSettledSubscribersRef.current.add(callback);
835
+ return () => {
836
+ pipelineSettledSubscribersRef.current.delete(callback);
837
+ };
838
+ },
839
+ []
840
+ );
841
+
842
+ const resetPipelineSettled = useCallback(() => {
843
+ pipelineGenRef.current++;
844
+ hasBlobInFlightRef.current = false;
845
+ pipelineResetCooldownRef.current = true;
846
+ requestAnimationFrame(() => {
847
+ pipelineResetCooldownRef.current = false;
848
+ });
849
+ },
850
+ []
851
+ );
852
+
853
+
854
+ // Notify that an export is starting (called from external canvas components)
855
+ // This fires immediately when a change is detected, before the export completes
856
+ const notifyPendingExport = useCallback(
857
+ (placement: string) => {
858
+ // Skip if this placement is already pending — avoids spamming subscribers
859
+ // on every canvas change during drag
860
+ if (pendingPlacementsRef.current.has(placement)) {
861
+ return;
862
+ }
863
+
864
+ // Track pending placement
865
+ pendingPlacementsRef.current.add(placement);
866
+
867
+ // Also track pending mockupIds for this placement
868
+ const pendingIds: string[] = [];
869
+ if (productContext?.product?.mockups) {
870
+ productContext.product.mockups.forEach((mockup: any) => {
871
+ if (mockup.placement === placement || !mockup.placement) {
872
+ pendingMockupIdsRef.current.add(mockup.id);
873
+ pendingIds.push(mockup.id);
874
+ }
875
+ });
876
+ }
877
+ console.log(`[RTP] notifyPendingExport("${placement}"): marked ${pendingIds.length} mockup(s) pending — [${pendingIds.slice(0, 5).join(', ')}${pendingIds.length > 5 ? '...' : ''}]`);
878
+
879
+ pendingBlobSubscribersRef.current.forEach((cb) => cb(placement));
880
+ },
881
+ [productContext?.product?.mockups]
882
+ );
883
+
884
+ // Roll back a previous notifyPendingExport when the actual export turned
885
+ // out to be a no-op. Clears the placement / mockup pending tracking and
886
+ // notifies cancellation subscribers so loading UI can be dismissed.
887
+ const notifyPendingExportCancelled = useCallback(
888
+ (placement: string) => {
889
+ // If we never marked this placement as pending, nothing to do.
890
+ if (!pendingPlacementsRef.current.has(placement)) {
891
+ return;
892
+ }
893
+ pendingPlacementsRef.current.delete(placement);
894
+
895
+ // Also clear any mockupIds we tagged for this placement.
896
+ const clearedIds: string[] = [];
897
+ if (productContext?.product?.mockups) {
898
+ productContext.product.mockups.forEach((mockup: any) => {
899
+ if (mockup.placement === placement || !mockup.placement) {
900
+ if (pendingMockupIdsRef.current.has(mockup.id)) {
901
+ pendingMockupIdsRef.current.delete(mockup.id);
902
+ clearedIds.push(mockup.id);
903
+ }
904
+ }
905
+ });
906
+ }
907
+ console.log(
908
+ `[RTP] notifyPendingExportCancelled("${placement}"): cleared ${clearedIds.length} mockup(s) — [${clearedIds.slice(0, 5).join(", ")}${clearedIds.length > 5 ? "..." : ""}]`
909
+ );
910
+
911
+ pendingExportCancelledSubscribersRef.current.forEach((cb) => cb(placement));
912
+ },
913
+ [productContext?.product?.mockups]
914
+ );
915
+
916
+ // ============================================================================
917
+ // CENTRALIZED URL MANAGEMENT METHODS (NEW)
918
+ // ============================================================================
919
+
920
+ /**
921
+ * Get mockup URL from centralized cache
922
+ * Returns null if the mockup is currently pending (new export in progress)
923
+ * This prevents stale mockups from being displayed during artwork changes
924
+ */
925
+ const getMockupUrl = useCallback((mockupId: string): string | null => {
926
+ // Return null for pending mockups to avoid showing stale images
927
+ if (pendingMockupIdsRef.current.has(mockupId)) {
928
+ console.log(`[RTP] getMockupUrl("${mockupId}") → null (pending)`);
929
+ return null;
930
+ }
931
+ const cached = mockupUrlCacheRef.current.get(mockupId);
932
+ return cached?.url ?? null;
933
+ }, []);
934
+
935
+ /**
936
+ * Get multiple mockup URLs at once
937
+ * Returns null for any mockups that are currently pending
938
+ */
939
+ const getMockupUrls = useCallback((mockupIds: string[]): Record<string, string | null> => {
940
+ const result: Record<string, string | null> = {};
941
+ for (const mockupId of mockupIds) {
942
+ // Return null for pending mockups to avoid showing stale images
943
+ if (pendingMockupIdsRef.current.has(mockupId)) {
944
+ result[mockupId] = null;
945
+ continue;
946
+ }
947
+ const cached = mockupUrlCacheRef.current.get(mockupId);
948
+ result[mockupId] = cached?.url ?? null;
949
+ }
950
+ return result;
951
+ }, []);
952
+
953
+ /**
954
+ * Check if a mockup is currently loading
955
+ */
956
+ const isMockupLoading = useCallback((mockupId: string): boolean => {
957
+ return pendingMockupIdsRef.current.has(mockupId);
958
+ }, []);
959
+
960
+ /**
961
+ * Register a blob URL with lifecycle management
962
+ */
963
+ const registerBlobUrl = useCallback((blobOrUrl: Blob | string, placementName?: string): string => {
964
+ let url: string;
965
+
966
+ if (blobOrUrl instanceof Blob) {
967
+ // Enforce blob URL limit before creating new one
968
+ if (registeredBlobUrlsRef.current.size >= MAX_CONCURRENT_BLOBS) {
969
+ // Revoke oldest blob URL (first entry in Set maintains insertion order)
970
+ const oldest = registeredBlobUrlsRef.current.values().next().value;
971
+ if (oldest) {
972
+ URL.revokeObjectURL(oldest);
973
+ registeredBlobUrlsRef.current.delete(oldest);
974
+
975
+ // Also remove from URL cache if present
976
+ mockupUrlCacheRef.current.forEach((value, key) => {
977
+ if (value.url === oldest) {
978
+ mockupUrlCacheRef.current.delete(key);
979
+ }
980
+ });
981
+ }
982
+ }
983
+
984
+ url = URL.createObjectURL(blobOrUrl);
985
+ registeredBlobUrlsRef.current.add(url);
986
+ } else {
987
+ url = blobOrUrl;
988
+ }
989
+
990
+ return url;
991
+ }, []);
992
+
993
+ // Store subscription functions in refs so they remain stable across context recreations
994
+ // This prevents race conditions where notifications are lost during re-subscription
995
+ const subscribeMockupResultsRef = useRef(subscribeMockupResults);
996
+ const subscribeMockupResultByIdRef = useRef(subscribeMockupResultById);
997
+ const subscribePendingBlobRef = useRef(subscribePendingBlob);
998
+ const subscribeBlobReceivedRef = useRef(subscribeBlobReceived);
999
+ const subscribeBlobSentRef = useRef(subscribeBlobSent);
1000
+ const subscribePipelineSettledRef = useRef(subscribePipelineSettled);
1001
+ const subscribePendingExportCancelledRef = useRef(
1002
+ subscribePendingExportCancelled,
1003
+ );
1004
+ subscribeMockupResultsRef.current = subscribeMockupResults;
1005
+ subscribeMockupResultByIdRef.current = subscribeMockupResultById;
1006
+ subscribePendingBlobRef.current = subscribePendingBlob;
1007
+ subscribeBlobReceivedRef.current = subscribeBlobReceived;
1008
+ subscribeBlobSentRef.current = subscribeBlobSent;
1009
+ subscribePipelineSettledRef.current = subscribePipelineSettled;
1010
+ subscribePendingExportCancelledRef.current = subscribePendingExportCancelled;
1011
+
1012
+ // Stable wrappers that delegate to the ref
1013
+ const stableSubscribeMockupResults = useCallback(
1014
+ (callback: (results: MockupResult[]) => void) => {
1015
+ return subscribeMockupResultsRef.current(callback);
1016
+ },
1017
+ []
1018
+ );
1019
+
1020
+ const stableSubscribeMockupResultById = useCallback(
1021
+ (mockupId: string, callback: (result: MockupResult) => void) => {
1022
+ return subscribeMockupResultByIdRef.current(mockupId, callback);
1023
+ },
1024
+ []
1025
+ );
1026
+
1027
+ const stableSubscribePendingBlob = useCallback(
1028
+ (callback: (placement: string) => void) => {
1029
+ return subscribePendingBlobRef.current(callback);
1030
+ },
1031
+ []
1032
+ );
1033
+
1034
+ const stableSubscribePendingExportCancelled = useCallback(
1035
+ (callback: (placement: string) => void) => {
1036
+ return subscribePendingExportCancelledRef.current(callback);
1037
+ },
1038
+ []
1039
+ );
1040
+
1041
+ const stableSubscribeBlobReceived = useCallback(
1042
+ (callback: (placement: string) => void) => {
1043
+ return subscribeBlobReceivedRef.current(callback);
1044
+ },
1045
+ []
1046
+ );
1047
+
1048
+ const stableSubscribeBlobSent = useCallback(
1049
+ (callback: (placement: string) => void) => {
1050
+ return subscribeBlobSentRef.current(callback);
1051
+ },
1052
+ []
1053
+ );
1054
+
1055
+ const stableSubscribePipelineSettled = useCallback(
1056
+ (callback: () => void) => {
1057
+ return subscribePipelineSettledRef.current(callback);
1058
+ },
1059
+ []
1060
+ );
1061
+
1062
+ // Create context value - STABLE reference when possible
1063
+ // NOTE: mockupResults reads from ref - it won't trigger re-renders when mockups arrive.
1064
+ // Components should use subscribeMockupResults() or subscribeMockupResultById() for reactive updates.
1065
+ const contextValue = useMemo<RealtimeContextValue>(
1066
+ () => ({
1067
+ isEnabled,
1068
+ isConnected,
1069
+ isConfigured,
1070
+ // Read from ref - this is a snapshot at render time, NOT reactive
1071
+ // For reactive updates, use getMockupResultsImmediate() or subscribe functions
1072
+ get mockupResults() {
1073
+ return mockupResultsRef.current;
1074
+ },
1075
+ isPendingMockups: isPendingMockupsRef.current,
1076
+ canvasBlobsSent: canvasBlobsSentRef.current,
1077
+ colorBlobsSent: colorBlobsSentRef.current,
1078
+ lastBlobSentTime: lastBlobSentTimeRef.current,
1079
+ canvasExportSize,
1080
+ mockupWidth,
1081
+ placementDimensions,
1082
+ enableRealtime,
1083
+ disableRealtime,
1084
+ sendCanvasBlob,
1085
+ sendCanvasState,
1086
+ updateMockupIds: updateMockupIdsRaw,
1087
+ updatePlacementSettings: updatePlacementSettingsWrapped,
1088
+ getMockupResultsImmediate: () => mockupResultsRef.current,
1089
+ subscribeMockupResults: stableSubscribeMockupResults,
1090
+ subscribeMockupResultById: stableSubscribeMockupResultById,
1091
+ subscribePendingBlob: stableSubscribePendingBlob,
1092
+ subscribeBlobReceived: stableSubscribeBlobReceived,
1093
+ subscribeBlobSent: stableSubscribeBlobSent,
1094
+ subscribePipelineSettled: stableSubscribePipelineSettled,
1095
+ resetPipelineSettled,
1096
+ notifyPendingExport,
1097
+ notifyPendingExportCancelled,
1098
+ subscribePendingExportCancelled: stableSubscribePendingExportCancelled,
1099
+ flushPendingBlobs,
1100
+ hasPendingBlobs,
1101
+ // Centralized URL management
1102
+ getMockupUrl,
1103
+ getMockupUrls,
1104
+ isMockupLoading,
1105
+ registerBlobUrl,
1106
+ // WebRTC streaming
1107
+ subscribeRTCTiming,
1108
+ }),
1109
+ [
1110
+ isEnabled,
1111
+ isConnected,
1112
+ isConfigured,
1113
+ // mockupResults removed from deps - it's a getter that reads from ref
1114
+ canvasExportSize,
1115
+ mockupWidth,
1116
+ placementDimensions,
1117
+ enableRealtime,
1118
+ disableRealtime,
1119
+ sendCanvasBlob,
1120
+ sendCanvasState,
1121
+ updateMockupIdsRaw,
1122
+ updatePlacementSettingsWrapped,
1123
+ stableSubscribeMockupResults,
1124
+ stableSubscribeMockupResultById,
1125
+ notifyPendingExport,
1126
+ notifyPendingExportCancelled,
1127
+ stableSubscribePendingExportCancelled,
1128
+ flushPendingBlobs,
1129
+ hasPendingBlobs,
1130
+ getMockupUrl,
1131
+ getMockupUrls,
1132
+ isMockupLoading,
1133
+ registerBlobUrl,
1134
+ ]
1135
+ );
1136
+
1137
+ return (
1138
+ <RealtimeContext.Provider value={contextValue}>
1139
+ {children}
1140
+ </RealtimeContext.Provider>
1141
+ );
1142
+ }
1143
+
1144
+ /**
1145
+ * Hook to access realtime context
1146
+ * Throws if not inside RealtimeProvider
1147
+ */
1148
+ export function useRealtime(): RealtimeContextValue {
1149
+ const context = useContext(RealtimeContext);
1150
+ if (!context) {
1151
+ throw new Error("useRealtime must be used within a RealtimeProvider");
1152
+ }
1153
+ return context;
1154
+ }
1155
+
1156
+ /**
1157
+ * Hook to optionally access realtime context
1158
+ * Returns undefined if not inside RealtimeProvider
1159
+ */
1160
+ export function useRealtimeOptional(): RealtimeContextValue | undefined {
1161
+ return useContext(RealtimeContext);
1162
+ }