@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,603 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ createContext,
5
+ useState,
6
+ useCallback,
7
+ type ReactNode,
8
+ useRef,
9
+ useEffect,
10
+ } from "react";
11
+ import type { Artwork } from "../patterns/Product";
12
+ import type { CatalogProduct } from "@snowcone-app/sdk";
13
+
14
+ // Image update request for queuing
15
+ export interface ImageUpdateRequest {
16
+ id: string; // Unique identifier for this request
17
+ execute: () => void; // Function to execute the update
18
+ priority?: number; // Optional priority (higher = more important)
19
+ url?: string; // Optional URL to check against cache
20
+ }
21
+
22
+ export interface ShopContextValue {
23
+ // Shop configuration
24
+ endpoint?: string;
25
+
26
+ // Artwork selection
27
+ artworks: Artwork[];
28
+ selectedArtwork?: Artwork;
29
+ addArtwork: (artwork: Artwork) => void;
30
+ setSelectedArtwork: (artwork: Artwork | undefined) => void;
31
+
32
+ // Image generation throttling
33
+ queueImageUpdate: (request: ImageUpdateRequest) => void;
34
+ cancelImageUpdate: (id: string) => void;
35
+
36
+ // Product data caching (for performance optimization)
37
+ getProduct: (productId: string) => CatalogProduct | undefined;
38
+ addProduct: (product: CatalogProduct) => void;
39
+ addProducts: (products: CatalogProduct[]) => void;
40
+ clearProductCache: () => void;
41
+
42
+ // Future shop-level features:
43
+ // currency?: string;
44
+ // theme?: 'light' | 'dark';
45
+ // cartItems?: CartItem[];
46
+ // addToCart?: (item: CartItem) => void;
47
+ }
48
+
49
+ export const ShopContext = createContext<ShopContextValue | undefined>(
50
+ undefined
51
+ );
52
+
53
+ export interface ShopProps {
54
+ children: ReactNode;
55
+ endpoint?: string;
56
+ mockupUrl?: string;
57
+ /**
58
+ * Public image-CDN resolver host (`img.snowcone.app` in prod; the
59
+ * workspace-local `img` host in dev). Static `<img>` builders use this so the
60
+ * resolver can sign the render URL. Defaults to `img.snowcone.app` when unset.
61
+ */
62
+ resolverUrl?: string;
63
+ /**
64
+ * Your public Shop ID (the same value used as `&shop=` in mockup/buy URLs).
65
+ * Falls back to NEXT_PUBLIC_SNOWCONE_SHOP_ID. Omit for demo mode.
66
+ */
67
+ shop?: string;
68
+ /**
69
+ * Optional async signer for **L3 (signed-URL) shops**. The UI builds public
70
+ * mockup `<img>` URLs on the client; a shop at `require_signed_urls` rejects
71
+ * any URL without an `&signature`. Provide a function that returns the signed
72
+ * URL — typically a thin call to your own `/api/sign` BFF (the secret stays on
73
+ * your server) — and the UI signs every static mockup URL through it before
74
+ * rendering. Omit for L0 shops; URLs render as built.
75
+ *
76
+ * Build it with the SDK's batching `bffSigner` (a grid of N images makes ~1
77
+ * `/api/sign` request, not N), or pass your own `(url) => Promise<string>`.
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * import { bffSigner } from "@snowcone-app/sdk";
82
+ * const signMockupUrl = bffSigner("/api/sign"); // create once (stable)
83
+ * <Shop shop={shopId} signMockupUrl={signMockupUrl}>…</Shop>
84
+ * ```
85
+ */
86
+ signMockupUrl?: (url: string) => Promise<string>;
87
+ /**
88
+ * Initial artwork to select on mount (avoids layout shift)
89
+ */
90
+ initialArtwork?: Artwork;
91
+ }
92
+
93
+ /**
94
+ * Shop - Global shop context provider with artwork and image throttling
95
+ *
96
+ * A pattern component that provides centralized state management for shop-wide
97
+ * concerns like artwork selection, product caching, and coordinated image generation
98
+ * throttling. Wrap your entire app (or shop section) in this provider.
99
+ *
100
+ * Features:
101
+ * - Centralized artwork state shared across all Product instances
102
+ * - Image update queueing with intelligent throttling (5 images/sec)
103
+ * - Product data caching for performance
104
+ * - LRU URL cache to bypass throttling for repeated requests
105
+ * - Per-component throttling (500ms) to prevent rapid updates
106
+ * - Priority-based queue processing
107
+ * - Automatic SDK configuration
108
+ * - Mode/endpoint configuration cascade to child components
109
+ *
110
+ * **Image Throttling:**
111
+ * - Limits mockup generation to 5 images per second (non-cached)
112
+ * - Cached URLs bypass throttling entirely
113
+ * - Per-component throttle prevents same component from spamming
114
+ * - Priority queue ensures important images load first
115
+ * - LRU cache (max 100 URLs) remembers recent requests
116
+ *
117
+ * **Artwork Management:**
118
+ * - Centralized artwork collection
119
+ * - Shared selected artwork across all products
120
+ * - Automatic sync between Product instances
121
+ *
122
+ * **Product Caching:**
123
+ * - In-memory product cache to reduce API calls
124
+ * - Add/get/clear cache methods
125
+ * - Useful for product lists and galleries
126
+ *
127
+ * @example
128
+ * ```tsx
129
+ * // Basic shop setup
130
+ * <Shop endpoint="http://localhost:3000">
131
+ * <ProductList limit={12}>
132
+ * <ProductCard variant="overlay" />
133
+ * </ProductList>
134
+ * </Shop>
135
+ * ```
136
+ *
137
+ * @example
138
+ * ```tsx
139
+ * // Multiple products sharing artwork state
140
+ * <Shop mockupUrl="https://api.example.com">
141
+ * <ArtSelector />
142
+ *
143
+ * <div className="grid grid-cols-2 gap-4">
144
+ * <Product productId="shirt-123">
145
+ * <ProductImage />
146
+ * </Product>
147
+ * <Product productId="mug-456">
148
+ * <ProductImage />
149
+ * </Product>
150
+ * </div>
151
+ * </Shop>
152
+ * ```
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * // With environment variables (Next.js)
157
+ * // .env.local:
158
+ * // NEXT_PUBLIC_MERCH_ENDPOINT=http://localhost:3000
159
+ * // NEXT_PUBLIC_MERCH_MOCKUP_URL=https://mockup.example.com
160
+ * // NEXT_PUBLIC_SNOWCONE_SHOP_ID=acc_123
161
+ *
162
+ * <Shop>
163
+ * <YourApp />
164
+ * </Shop>
165
+ * ```
166
+ *
167
+ * @example
168
+ * ```tsx
169
+ * // Access shop context in child components
170
+ * function MyComponent() {
171
+ * const shop = useShopOptional();
172
+ *
173
+ * return (
174
+ * <div>
175
+ * <p>Artworks: {shop?.artworks.length}</p>
176
+ *
177
+ * <button onClick={() => shop?.setSelectedArtwork(artwork)}>
178
+ * Select Artwork
179
+ * </button>
180
+ * </div>
181
+ * );
182
+ * }
183
+ * ```
184
+ *
185
+ * @example
186
+ * ```tsx
187
+ * // Product caching for performance
188
+ * function ProductCatalog() {
189
+ * const shop = useShop();
190
+ *
191
+ * useEffect(() => {
192
+ * // Cache products after fetching
193
+ * fetchProducts().then(products => {
194
+ * shop.addProducts(products);
195
+ * });
196
+ * }, []);
197
+ *
198
+ * // Later, retrieve from cache
199
+ * const cachedProduct = shop.getProduct('shirt-123');
200
+ * }
201
+ * ```
202
+ *
203
+ * @param children - Child components that will have access to shop context
204
+ * @param endpoint - API endpoint URL (falls back to NEXT_PUBLIC_MERCH_ENDPOINT)
205
+ * @param mockupUrl - Mockup generation API URL (falls back to NEXT_PUBLIC_MERCH_MOCKUP_URL)
206
+ * @param shop - Your public Shop ID (falls back to NEXT_PUBLIC_SNOWCONE_SHOP_ID)
207
+ */
208
+ export function Shop({
209
+ children,
210
+ endpoint,
211
+ mockupUrl,
212
+ resolverUrl,
213
+ shop,
214
+ signMockupUrl,
215
+ initialArtwork,
216
+ }: ShopProps) {
217
+ // Publish runtime config on `window.snowcone` so the internal live-preview
218
+ // builder (`mockupUrl()`) resolves the same base/shop on the client. The
219
+ // global SDK `config()` singleton was removed with the image-CDN redesign
220
+ // (ADR-0075 §7) — the public developer path is now `getMockupUrl({ shop })`.
221
+ if (typeof window !== "undefined") {
222
+ const snow = ((window as any).snowcone ||= {});
223
+ if (mockupUrl) snow.mockupUrl = mockupUrl;
224
+ if (resolverUrl) snow.resolver = resolverUrl;
225
+ if (endpoint) snow.endpoint = endpoint;
226
+ if (shop) snow.shop = shop;
227
+ // L3 signer (see ShopProps.signMockupUrl). Static mockup `<img>` builders
228
+ // read this to sign URLs before rendering.
229
+ if (signMockupUrl) snow.signMockupUrl = signMockupUrl;
230
+ }
231
+
232
+ // Run configuration checks in development
233
+ useEffect(() => {
234
+ if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
235
+ // Delay check to ensure DOM is ready
236
+ setTimeout(() => {
237
+ import('../utils/dev-warnings').then(({ runConfigChecks }) => {
238
+ runConfigChecks();
239
+ });
240
+ }, 1000);
241
+ }
242
+ }, []);
243
+ const [artworks, setArtworks] = useState<Artwork[]>([]);
244
+ const [selectedArtwork, setSelectedArtworkState] = useState<
245
+ Artwork | undefined
246
+ >(initialArtwork);
247
+
248
+ // Product data cache (use ref to avoid changing reference)
249
+ const productCacheRef = useRef<Map<string, CatalogProduct>>(new Map());
250
+
251
+ // Image update queue management
252
+ const updateQueueRef = useRef<Map<string, ImageUpdateRequest>>(new Map());
253
+ const processingRef = useRef<boolean>(false);
254
+ const processTimeoutRef = useRef<NodeJS.Timeout | null>(null);
255
+ const recentProcessTimesRef = useRef<number[]>([]);
256
+ const componentLastUpdateRef = useRef<Map<string, number>>(new Map());
257
+ const componentTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
258
+
259
+ // URL cache for tracking recently requested images (LRU with max 100 entries)
260
+ const urlCacheRef = useRef<Set<string>>(new Set());
261
+ const urlCacheOrderRef = useRef<string[]>([]);
262
+
263
+ const addArtwork = useCallback((artwork: Artwork) => {
264
+ setArtworks((prev) => {
265
+ const exists = prev.some((a) => a.src === artwork.src);
266
+ if (exists) {
267
+ return prev.map((a) => (a.src === artwork.src ? artwork : a));
268
+ }
269
+ return [...prev, artwork];
270
+ });
271
+ }, []);
272
+
273
+ const setSelectedArtwork = useCallback((artwork: Artwork | undefined) => {
274
+ setSelectedArtworkState(artwork);
275
+ }, []);
276
+
277
+ // Product cache methods
278
+ const getProduct = useCallback((productId: string) => {
279
+ return productCacheRef.current.get(productId);
280
+ }, []);
281
+
282
+ const addProduct = useCallback((product: CatalogProduct) => {
283
+ productCacheRef.current.set(product.id, product);
284
+ }, []);
285
+
286
+ const addProducts = useCallback((products: CatalogProduct[]) => {
287
+ products.forEach((product) => {
288
+ productCacheRef.current.set(product.id, product);
289
+ });
290
+ }, []);
291
+
292
+ const clearProductCache = useCallback(() => {
293
+ productCacheRef.current.clear();
294
+ }, []);
295
+
296
+ // Add URL to cache (LRU with max 100 entries)
297
+ const addToUrlCache = useCallback((url: string) => {
298
+ if (!url) return;
299
+
300
+ // If already in cache, move to end (most recently used)
301
+ if (urlCacheRef.current.has(url)) {
302
+ const index = urlCacheOrderRef.current.indexOf(url);
303
+ if (index > -1) {
304
+ urlCacheOrderRef.current.splice(index, 1);
305
+ }
306
+ }
307
+
308
+ // Add to end of order list
309
+ urlCacheOrderRef.current.push(url);
310
+ urlCacheRef.current.add(url);
311
+
312
+ // If over 100 entries, remove oldest
313
+ if (urlCacheOrderRef.current.length > 100) {
314
+ const oldestUrl = urlCacheOrderRef.current.shift();
315
+ if (oldestUrl) {
316
+ urlCacheRef.current.delete(oldestUrl);
317
+ }
318
+ }
319
+
320
+ }, []);
321
+
322
+ // Check if URL is in cache
323
+ const isUrlCached = useCallback((url: string): boolean => {
324
+ return url ? urlCacheRef.current.has(url) : false;
325
+ }, []);
326
+
327
+ // Process the queue - allows up to 5 simultaneous images per second
328
+ const processQueue = useCallback(() => {
329
+ if (processingRef.current || updateQueueRef.current.size === 0) {
330
+ return;
331
+ }
332
+
333
+ processingRef.current = true;
334
+
335
+ // Rate limit: 5 images per second (but only for non-cached URLs)
336
+ const maxPerSecond = 5;
337
+ const windowMs = 1000; // 1 second window
338
+
339
+ // Clean up old timestamps (older than 1 second)
340
+ const now = Date.now();
341
+ recentProcessTimesRef.current = recentProcessTimesRef.current.filter(
342
+ (time) => now - time < windowMs
343
+ );
344
+
345
+ // Get all requests and sort by priority
346
+ const allRequests = Array.from(updateQueueRef.current.values()).sort(
347
+ (a, b) => (b.priority || 0) - (a.priority || 0)
348
+ );
349
+
350
+ // Separate cached and non-cached requests
351
+ const cachedRequests = allRequests.filter(
352
+ (req) => req.url && isUrlCached(req.url)
353
+ );
354
+ const nonCachedRequests = allRequests.filter(
355
+ (req) => !req.url || !isUrlCached(req.url)
356
+ );
357
+
358
+ // Process cached requests immediately (no rate limit)
359
+ const toProcess = [...cachedRequests];
360
+
361
+ // Add non-cached requests up to available rate limit slots
362
+ const availableSlots = maxPerSecond - recentProcessTimesRef.current.length;
363
+
364
+ if (nonCachedRequests.length > 0) {
365
+ if (availableSlots <= 0) {
366
+ // Rate limit reached for non-cached requests
367
+ const oldestTime = recentProcessTimesRef.current[0];
368
+ const waitTime = Math.max(0, oldestTime + windowMs - now + 10);
369
+
370
+ if (cachedRequests.length === 0) {
371
+ // No cached requests to process, need to wait
372
+ processingRef.current = false;
373
+ processTimeoutRef.current = setTimeout(() => {
374
+ processQueue();
375
+ }, waitTime);
376
+ return;
377
+ }
378
+ // Otherwise process cached requests and wait for non-cached
379
+ } else {
380
+ // Add non-cached requests up to available slots
381
+ toProcess.push(...nonCachedRequests.slice(0, availableSlots));
382
+ }
383
+ }
384
+
385
+ if (toProcess.length > 0) {
386
+ const cachedCount = cachedRequests.length;
387
+ const nonCachedCount = toProcess.length - cachedCount;
388
+
389
+
390
+ // Remove from queue and track
391
+ toProcess.forEach((request) => {
392
+ updateQueueRef.current.delete(request.id);
393
+
394
+ // Only count non-cached requests against rate limit
395
+ if (!request.url || !isUrlCached(request.url)) {
396
+ recentProcessTimesRef.current.push(now);
397
+ }
398
+
399
+ // Track when this component was last updated (for component throttling)
400
+ componentLastUpdateRef.current.set(request.id, now);
401
+ });
402
+
403
+ // Execute all requests
404
+ toProcess.forEach((request) => {
405
+ try {
406
+ request.execute();
407
+ // Add URL to cache if provided and not already cached
408
+ if (request.url && !isUrlCached(request.url)) {
409
+ addToUrlCache(request.url);
410
+ }
411
+ } catch (error) {
412
+ console.error("[Shop] Error executing image update:", error);
413
+ }
414
+ });
415
+ }
416
+
417
+ processingRef.current = false;
418
+
419
+ // If more updates in queue, check again after a short delay
420
+ if (updateQueueRef.current.size > 0) {
421
+ // Check again soon to see if we can process more
422
+ processTimeoutRef.current = setTimeout(() => {
423
+ processQueue();
424
+ }, 100); // Check every 100ms for available slots
425
+ }
426
+ }, [addToUrlCache, isUrlCached]);
427
+
428
+ // Track the last URL requested by each component (for deduplication)
429
+ const componentLastUrlRef = useRef<Map<string, string>>(new Map());
430
+
431
+ // Queue an image update with per-component throttling (bypassed for cached URLs)
432
+ const queueImageUpdate = useCallback(
433
+ (request: ImageUpdateRequest) => {
434
+ const componentThrottle = 500; // 500ms per component
435
+ const now = Date.now();
436
+
437
+ // Cancel any existing timeout for this component (prevents orphaned timeouts)
438
+ const existingTimeout = componentTimeoutsRef.current.get(request.id);
439
+ if (existingTimeout) {
440
+ clearTimeout(existingTimeout);
441
+ componentTimeoutsRef.current.delete(request.id);
442
+ }
443
+
444
+ // Deduplicate: Skip if this component just requested the exact same URL
445
+ // This handles React StrictMode double-invocations and rapid identical requests
446
+ const lastUrl = componentLastUrlRef.current.get(request.id);
447
+ if (request.url && lastUrl === request.url) {
448
+ // Same URL as last request - skip entirely
449
+ return;
450
+ }
451
+
452
+ // Track this URL as the last requested for this component
453
+ if (request.url) {
454
+ componentLastUrlRef.current.set(request.id, request.url);
455
+ }
456
+
457
+ // Check if URL is cached - if so, bypass throttling
458
+ const isCached = request.url && isUrlCached(request.url);
459
+
460
+ if (isCached) {
461
+ } else {
462
+ // Check if this component updated recently (only for non-cached URLs)
463
+ const lastUpdate = componentLastUpdateRef.current.get(request.id);
464
+ if (lastUpdate) {
465
+ const timeSinceLastUpdate = now - lastUpdate;
466
+ if (timeSinceLastUpdate < componentThrottle) {
467
+ // Component is throttled, update the queued request but don't process yet
468
+ updateQueueRef.current.set(request.id, request);
469
+
470
+ // Schedule processing after throttle period and track the timeout
471
+ const timeoutId = setTimeout(() => {
472
+ componentTimeoutsRef.current.delete(request.id);
473
+ if (!processingRef.current) {
474
+ processQueue();
475
+ }
476
+ }, componentThrottle - timeSinceLastUpdate);
477
+ componentTimeoutsRef.current.set(request.id, timeoutId);
478
+ return;
479
+ }
480
+ }
481
+ }
482
+
483
+ // Add or replace the request in the queue
484
+ updateQueueRef.current.set(request.id, request);
485
+
486
+ // If not already processing, start processing
487
+ if (!processingRef.current) {
488
+ processQueue();
489
+ }
490
+ },
491
+ [processQueue, isUrlCached]
492
+ );
493
+
494
+ // Cancel a queued image update (when component unmounts)
495
+ const cancelImageUpdate = useCallback((id: string) => {
496
+ if (updateQueueRef.current.has(id)) {
497
+ updateQueueRef.current.delete(id);
498
+ }
499
+ // Also clean up the last update timestamp and last URL
500
+ componentLastUpdateRef.current.delete(id);
501
+ componentLastUrlRef.current.delete(id);
502
+
503
+ // Cancel any pending timeout for this component
504
+ const timeout = componentTimeoutsRef.current.get(id);
505
+ if (timeout) {
506
+ clearTimeout(timeout);
507
+ componentTimeoutsRef.current.delete(id);
508
+ }
509
+ }, []);
510
+
511
+ // Cleanup on unmount
512
+ useEffect(() => {
513
+ return () => {
514
+ if (processTimeoutRef.current) {
515
+ clearTimeout(processTimeoutRef.current);
516
+ }
517
+ // Clear all component timeouts
518
+ componentTimeoutsRef.current.forEach((timeout) => clearTimeout(timeout));
519
+ componentTimeoutsRef.current.clear();
520
+ };
521
+ }, []);
522
+
523
+ const contextValue: ShopContextValue = {
524
+ endpoint,
525
+ artworks,
526
+ selectedArtwork,
527
+ addArtwork,
528
+ setSelectedArtwork,
529
+ queueImageUpdate,
530
+ cancelImageUpdate,
531
+ getProduct,
532
+ addProduct,
533
+ addProducts,
534
+ clearProductCache,
535
+ };
536
+
537
+ return (
538
+ <ShopContext.Provider value={contextValue}>{children}</ShopContext.Provider>
539
+ );
540
+ }
541
+
542
+ /**
543
+ * useShop - Access shop-level state and methods
544
+ *
545
+ * Common use: Build custom art selectors with full control over UI/UX
546
+ *
547
+ * @see https://developers.snowcone.app/docs/react/components/select-artwork
548
+ *
549
+ * @example
550
+ * ```tsx
551
+ * // Custom artwork selector
552
+ * function MyCustomSelector() {
553
+ * const { setSelectedArtwork } = useShop();
554
+ *
555
+ * const selectArtwork = async (url: string) => {
556
+ * const img = new Image();
557
+ * img.onload = () => {
558
+ * setSelectedArtwork({
559
+ * type: 'regular',
560
+ * src: url,
561
+ * width: img.naturalWidth,
562
+ * height: img.naturalHeight,
563
+ * aspectRatio: img.naturalWidth / img.naturalHeight,
564
+ * });
565
+ * };
566
+ * img.src = url;
567
+ * };
568
+ *
569
+ * return <button onClick={() => selectArtwork(url)}>Select</button>;
570
+ * }
571
+ * ```
572
+ *
573
+ * @example
574
+ * ```tsx
575
+ * // Access artwork collection
576
+ * function ArtworkList() {
577
+ * const { artworks, selectedArtwork } = useShop();
578
+ * return <p>Total: {artworks.length}</p>;
579
+ * }
580
+ * ```
581
+ *
582
+ * @throws {Error} If used outside of Shop provider
583
+ * @returns {ShopContextValue} Shop context value
584
+ */
585
+ export function useShop() {
586
+ const context = React.useContext(ShopContext);
587
+ if (!context) {
588
+ throw new Error("useShop must be used within a Shop provider");
589
+ }
590
+ return context;
591
+ }
592
+
593
+ /**
594
+ * useShopOptional - Optional access to shop-level state
595
+ *
596
+ * Same as useShop() but returns undefined if not within a Shop provider
597
+ * instead of throwing an error.
598
+ *
599
+ * @returns {ShopContextValue | undefined} Shop context value or undefined
600
+ */
601
+ export function useShopOptional() {
602
+ return React.useContext(ShopContext);
603
+ }