@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,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
|
+
}
|