@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.
- package/CHANGELOG.md +26 -0
- package/README.md +18 -4
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1071 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,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
|
+
}
|