@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,850 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ createContext,
5
+ useState,
6
+ useEffect,
7
+ useMemo,
8
+ useCallback,
9
+ useRef,
10
+ } from "react";
11
+ import type { CatalogProduct } from "@snowcone-app/sdk";
12
+ import { getProduct, listProducts } from "@snowcone-app/sdk";
13
+ import { createDevFetcher } from "@snowcone-app/sdk/dev-fetcher";
14
+ import type {
15
+ OptionAttribute,
16
+ Combination,
17
+ OptionSelection,
18
+ ProductContext as CoreProductContext,
19
+ ImageAlignment,
20
+ } from "@snowcone-app/sdk";
21
+
22
+ /**
23
+ * Available clip shape options for artboards/placements
24
+ */
25
+ export type ClipShape = 'rectangle' | 'circle' | 'custom';
26
+ import {
27
+ deriveDefaultSelection,
28
+ toOptionAttributes,
29
+ toCombinations,
30
+ findBestCombination,
31
+ UniversalContextProvider,
32
+ createUniversalProvider,
33
+ } from "@snowcone-app/sdk";
34
+ import { useShopOptional } from "../patterns/ShopProvider";
35
+ // NOTE: Realtime functionality has been moved to RealtimeProvider for better performance.
36
+ // Use <RealtimeProvider> to wrap components that need realtime mockup updates.
37
+ // This prevents mockup result updates from causing re-renders in ProductContext consumers.
38
+
39
+ // Design-related interfaces (previously in DesignContext)
40
+ export interface PlacementDesign {
41
+ imageUrl: string;
42
+ alignment: ImageAlignment;
43
+ tiles?: 0.25 | 0.5 | 1 | 2 | 4;
44
+ // Future fields can be added here:
45
+ // filters?: string[];
46
+ // etc.
47
+ }
48
+
49
+ export type RegularArtwork = {
50
+ type: "regular";
51
+ src: string;
52
+ };
53
+
54
+ export type SeamlessPattern = {
55
+ type: "pattern";
56
+ src: string;
57
+ tileCount: 0.25 | 0.5 | 1 | 2 | 4;
58
+ };
59
+
60
+ export type Artwork = RegularArtwork | SeamlessPattern;
61
+
62
+ // Realtime mockup types
63
+ export interface MockupResult {
64
+ mockupId: string;
65
+ imageUrl: string;
66
+ }
67
+
68
+ export interface RealtimeState {
69
+ isEnabled: boolean;
70
+ isConnected: boolean;
71
+ isConfigured: boolean;
72
+ mockupResults: MockupResult[];
73
+ isPendingMockups: boolean;
74
+ canvasBlobsSent: number;
75
+ colorBlobsSent: number;
76
+ lastBlobSentTime: string | null;
77
+ canvasExportSize: { width: number; height: number };
78
+ mockupWidth: number;
79
+ placementDimensions: Array<{
80
+ label: string;
81
+ width?: number;
82
+ height?: number;
83
+ type: string;
84
+ }>;
85
+ // PERFORMANCE: Polling access to mockup results (avoids waiting for throttled state updates)
86
+ getMockupResultsImmediate?: () => MockupResult[];
87
+ // Subscribe for immediate notifications when any mockup arrives
88
+ subscribeMockupResults?: (callback: (results: MockupResult[]) => void) => () => void;
89
+ // PERFORMANCE: Subscribe for a specific mockupId only (more efficient for HeroProductImage)
90
+ subscribeMockupResultById?: (mockupId: string, callback: (result: MockupResult) => void) => () => void;
91
+ }
92
+
93
+ // Extended React Context with update methods and design functionality
94
+ export interface ReactProductContext extends CoreProductContext {
95
+ updateSelection?: (selection: OptionSelection) => void;
96
+ currentPrice?: number;
97
+
98
+ // Design functionality (previously in DesignProvider)
99
+ placements: Record<string, PlacementDesign>;
100
+ artworks: Artwork[];
101
+ selectedArtwork?: Artwork;
102
+ selectedPlacement?: string;
103
+ clipShapes: Record<string, ClipShape>;
104
+ clipShapeInExport: Record<string, boolean>;
105
+
106
+ // Design methods
107
+ setPlacementDesign: (
108
+ placement: string,
109
+ design: Partial<PlacementDesign>
110
+ ) => void;
111
+ getPlacementDesign: (placement: string) => PlacementDesign | undefined;
112
+ addArtwork: (artwork: Artwork) => void;
113
+ setSelectedArtwork: (artwork: Artwork | undefined) => void;
114
+ setSelectedPlacement: (placement: string) => void;
115
+ applyArtworkToPlacement: (placement: string, artwork?: Artwork) => void;
116
+ setPlacementClipShape: (placement: string, clipShape: ClipShape) => void;
117
+ getPlacementClipShape: (placement: string) => ClipShape;
118
+ setClipShapeInExport: (placement: string, includeInExport: boolean) => void;
119
+ getClipShapeInExport: (placement: string) => boolean;
120
+
121
+ // NOTE: Realtime mockup functionality has been moved to RealtimeProvider
122
+ // Use useRealtime() or useRealtimeOptional() from RealtimeProvider instead
123
+ }
124
+
125
+ // React Context
126
+ export const ProductContext = createContext<ReactProductContext | undefined>(
127
+ undefined
128
+ );
129
+
130
+ /**
131
+ * Data loading mode for the Product component.
132
+ *
133
+ * - "ssr": Server-Side Rendering mode. Product data will be provided via props
134
+ * (through RSC streaming or parent component). Never fetches client-side.
135
+ * Use this in Next.js App Router pages with Suspense boundaries.
136
+ *
137
+ * - "client": Client-Side mode. Component will fetch product data itself.
138
+ * Use this for client-only pages or when no server data is available.
139
+ *
140
+ * - "auto" (default): Automatic mode. Fetches only if productData is not provided.
141
+ * Legacy behavior for backwards compatibility.
142
+ */
143
+ export type ProductDataMode = "ssr" | "client" | "auto";
144
+
145
+ export interface ProductProviderProps {
146
+ children: React.ReactNode;
147
+ productId?: string;
148
+ productData?: CatalogProduct; // Allow passing product data directly to skip fetching
149
+ endpoint?: string;
150
+ source?: string;
151
+ fetcher?: typeof fetch;
152
+ className?: string;
153
+ initialSelection?: OptionSelection;
154
+ initialPlacements?: Record<string, PlacementDesign>;
155
+ renderLoading?: () => React.ReactNode;
156
+ renderError?: (error: any) => React.ReactNode;
157
+ /**
158
+ * Data loading mode. Determines how the component obtains product data.
159
+ * - "ssr": Wait for productData prop (never fetch). Use with Next.js RSC/Suspense.
160
+ * - "client": Always fetch client-side.
161
+ * - "auto": Fetch only if productData not provided (default, legacy behavior).
162
+ */
163
+ dataMode?: ProductDataMode;
164
+ /** @deprecated Use dataMode="ssr" instead. Skip client-side fetching. */
165
+ skipFetch?: boolean;
166
+ // NOTE: wsUrl and realtimeMockupWidth have moved to RealtimeProvider
167
+ }
168
+
169
+ /**
170
+ * Product - Context provider for product data, variant selection, and design management
171
+ *
172
+ * A composed pattern component that provides React context for product information,
173
+ * variant selection, pricing, artwork management, and placement designs. Acts as the
174
+ * central state container for product-related components.
175
+ *
176
+ * Features:
177
+ * - Automatic product data fetching from API endpoint
178
+ * - Variant selection and combination management
179
+ * - Dynamic price calculation based on selected options
180
+ * - Design/artwork management with placement-specific designs
181
+ * - Integration with Shop context for shared artwork state
182
+ * - Loading and error states with custom render props
183
+ * - Universal provider sync for cross-framework compatibility
184
+ * - SSR-friendly with optional data prop to skip fetching
185
+ *
186
+ * **Context Provided:**
187
+ * - Product data and metadata
188
+ * - Option attributes and combinations
189
+ * - Current selection and price
190
+ * - Artwork collection and selected artwork
191
+ * - Placement designs (alignment, tiles, etc.)
192
+ * - Methods to update selection, artwork, and placements
193
+ *
194
+ * **Integration with Shop:**
195
+ * - Inherits endpoint from Shop context if available
196
+ * - Shares artwork state across all Product instances in a Shop
197
+ * - Falls back to local state if used standalone
198
+ *
199
+ * @example
200
+ * ```tsx
201
+ * // Basic usage - fetch product by ID
202
+ * <Product productId="shirt-123">
203
+ * <ProductImage />
204
+ * <ProductOptions />
205
+ * <AddToCart />
206
+ * </Product>
207
+ * ```
208
+ *
209
+ * @example
210
+ * ```tsx
211
+ * // With initial selection and custom loading
212
+ * <Product
213
+ * productId="shirt-123"
214
+ * initialSelection={{ Size: 'M', Color: 'Blue' }}
215
+ * renderLoading={() => <Spinner />}
216
+ * renderError={(err) => <ErrorMessage error={err} />}
217
+ * >
218
+ * <ProductCard />
219
+ * </Product>
220
+ * ```
221
+ *
222
+ * @example
223
+ * ```tsx
224
+ * // Standalone with explicit data (no fetching)
225
+ * <Product
226
+ * productData={myProductData}
227
+ * initialPlacements={{
228
+ * Front: { imageUrl: 'logo.png', alignment: 'center' },
229
+ * Back: { imageUrl: 'text.png', alignment: 'top' }
230
+ * }}
231
+ * >
232
+ * <ArtAlignment placement="Front" />
233
+ * <ProductImage />
234
+ * </Product>
235
+ * ```
236
+ *
237
+ * @example
238
+ * ```tsx
239
+ * // With Shop context
240
+ * <Shop endpoint="http://localhost:3000">
241
+ * <Product productId="shirt-123">
242
+ * <ProductImage />
243
+ * </Product>
244
+ * <Product productId="mug-456">
245
+ * <ProductImage />
246
+ * </Product>
247
+ * </Shop>
248
+ * // Both products share artwork state from Shop
249
+ * ```
250
+ *
251
+ * @param children - Child components that consume product context
252
+ * @param productId - Product identifier to fetch
253
+ * @param productData - Explicit product data (skips fetching if provided)
254
+ * @param endpoint - API endpoint URL (overrides Shop context)
255
+ * @param source - Source identifier for data fetching (default: "auto")
256
+ * @param fetcher - Custom fetch function (e.g., for auth headers)
257
+ * @param className - Additional CSS classes for wrapper div
258
+ * @param initialSelection - Pre-select variant options (e.g., { Size: 'M', Color: 'Blue' })
259
+ * @param initialPlacements - Pre-configure placement designs
260
+ * @param renderLoading - Custom loading UI component
261
+ * @param renderError - Custom error UI component
262
+ */
263
+ export function Product({
264
+ children,
265
+ productId,
266
+ productData,
267
+ endpoint,
268
+ source = "auto",
269
+ fetcher,
270
+ className,
271
+ initialSelection,
272
+ initialPlacements,
273
+ renderLoading,
274
+ renderError,
275
+ dataMode = "auto",
276
+ skipFetch = false, // Deprecated, kept for backwards compatibility
277
+ }: ProductProviderProps) {
278
+ // Resolve effective mode: skipFetch takes precedence for backwards compatibility
279
+ const effectiveMode: ProductDataMode = skipFetch ? "ssr" : dataMode;
280
+
281
+ const [product, setProduct] = useState<CatalogProduct | undefined>(
282
+ productData
283
+ );
284
+ const [loading, setLoading] = useState(false);
285
+ const [error, setError] = useState<any>(null);
286
+ const [selection, setSelection] = useState<OptionSelection>({});
287
+
288
+ // Sync productData prop to state when it changes (e.g., from SSR streaming)
289
+ // This is essential for "ssr" mode where data arrives after initial mount
290
+ useEffect(() => {
291
+ if (productData) {
292
+ setProduct(productData);
293
+ setLoading(false);
294
+ setError(null);
295
+ }
296
+ }, [productData]);
297
+
298
+ // Check if we have a parent Shop context
299
+ const shopContext = useShopOptional();
300
+
301
+ // Use shop context values if available, otherwise use props
302
+ const effectiveEndpoint = endpoint ?? shopContext?.endpoint;
303
+
304
+ // Design state - use shop context if available, otherwise maintain local state
305
+ // Key the placements by productId to ensure they reset when product changes
306
+ // Initialize with initialPlacements if provided
307
+ const [placementsById, setPlacementsById] = useState<
308
+ Record<string, Record<string, PlacementDesign>>
309
+ >(() => {
310
+ if (initialPlacements && productId) {
311
+ return { [productId]: initialPlacements };
312
+ }
313
+ return {};
314
+ });
315
+
316
+ // Store alignment settings per artwork URL
317
+ // Key structure: { productId: { placement: { artworkUrl: ImageAlignment } } }
318
+ const [alignmentByArtwork, setAlignmentByArtwork] = useState<
319
+ Record<string, Record<string, Record<string, ImageAlignment>>>
320
+ >({});
321
+
322
+ // Get current product's placements
323
+ // IMPORTANT: Use a stable empty object reference to avoid triggering re-renders
324
+ // when there are no placements. Creating {} inline would create a new object each render.
325
+ const EMPTY_PLACEMENTS: Record<string, PlacementDesign> = useMemo(() => ({}), []);
326
+ const placements = productId ? placementsById[productId] || EMPTY_PLACEMENTS : EMPTY_PLACEMENTS;
327
+ const [localArtworks, setLocalArtworks] = useState<Artwork[]>([]);
328
+ const [localSelectedArtwork, setLocalSelectedArtwork] = useState<
329
+ Artwork | undefined
330
+ >();
331
+ const [localSelectedPlacement, setLocalSelectedPlacement] = useState<
332
+ string | undefined
333
+ >();
334
+
335
+ // Clip shapes per placement (keyed by productId -> placementLabel -> ClipShape)
336
+ const [clipShapesById, setClipShapesById] = useState<
337
+ Record<string, Record<string, ClipShape>>
338
+ >({});
339
+
340
+ // Clip shape export flag per placement (keyed by productId -> placementLabel -> boolean)
341
+ const [clipShapeInExportById, setClipShapeInExportById] = useState<
342
+ Record<string, Record<string, boolean>>
343
+ >({});
344
+
345
+ // Use shop context if available, otherwise use local state
346
+ const artworks = shopContext?.artworks ?? localArtworks;
347
+ const selectedArtwork = shopContext?.selectedArtwork ?? localSelectedArtwork;
348
+ const selectedPlacement = localSelectedPlacement;
349
+
350
+ // Get current product's clip shapes (default to rectangle if not set)
351
+ const clipShapes = productId ? clipShapesById[productId] || {} : {};
352
+
353
+ // Get current product's clip shape export flags (default to false if not set)
354
+ const clipShapeInExport = productId ? clipShapeInExportById[productId] || {} : {};
355
+
356
+ // Create universal provider
357
+ const universalProvider = useMemo(() => {
358
+ return createUniversalProvider({
359
+ endpoint: effectiveEndpoint,
360
+ productId,
361
+ fetcher,
362
+ autoLoad: false,
363
+ });
364
+ }, [effectiveEndpoint, productId]);
365
+
366
+ useEffect(() => {
367
+ // SSR mode: Never fetch, wait for productData to arrive via props
368
+ if (effectiveMode === "ssr") {
369
+ // If productData is already available, initialize selection
370
+ if (productData) {
371
+ setProduct(productData);
372
+
373
+ // Apply initial placements if provided
374
+ if (initialPlacements && (productId || productData.id)) {
375
+ const id = productId || productData.id;
376
+ setPlacementsById((prev) => {
377
+ if (!prev[id]) {
378
+ return { ...prev, [id]: initialPlacements };
379
+ }
380
+ return prev;
381
+ });
382
+ }
383
+
384
+ // Derive initial selection
385
+ const attrs = toOptionAttributes(productData);
386
+ const combos = toCombinations(productData);
387
+ const defaultSelection =
388
+ initialSelection || deriveDefaultSelection(attrs, combos);
389
+
390
+ // Initialize default colors for color placements
391
+ const colorPlacementDefaults: Record<string, string> = {};
392
+ if (productData.placements) {
393
+ productData.placements.forEach((placement: any) => {
394
+ if (
395
+ placement.type === "color" &&
396
+ !defaultSelection[placement.label]
397
+ ) {
398
+ colorPlacementDefaults[placement.label] = "#000000";
399
+ }
400
+ });
401
+ }
402
+
403
+ const finalSelection = { ...defaultSelection, ...colorPlacementDefaults };
404
+ setSelection(finalSelection);
405
+ }
406
+ // In SSR mode, don't fetch - data will arrive via productData prop
407
+ return;
408
+ }
409
+
410
+ // Auto mode: Skip fetching if productData was already provided
411
+ if (effectiveMode === "auto" && productData) {
412
+ setProduct(productData);
413
+
414
+ // Apply initial placements if provided
415
+ if (initialPlacements && (productId || productData.id)) {
416
+ const id = productId || productData.id;
417
+ setPlacementsById((prev) => {
418
+ if (!prev[id]) {
419
+ return { ...prev, [id]: initialPlacements };
420
+ }
421
+ return prev;
422
+ });
423
+ }
424
+
425
+ // Derive initial selection
426
+ const attrs = toOptionAttributes(productData);
427
+ const combos = toCombinations(productData);
428
+ const defaultSelection =
429
+ initialSelection || deriveDefaultSelection(attrs, combos);
430
+
431
+ // Initialize default colors for color placements
432
+ const colorPlacementDefaults: Record<string, string> = {};
433
+ if (productData.placements) {
434
+ productData.placements.forEach((placement: any) => {
435
+ if (
436
+ placement.type === "color" &&
437
+ !defaultSelection[placement.label]
438
+ ) {
439
+ colorPlacementDefaults[placement.label] = "#000000";
440
+ }
441
+ });
442
+ }
443
+
444
+ const finalSelection = { ...defaultSelection, ...colorPlacementDefaults };
445
+ setSelection(finalSelection);
446
+ return;
447
+ }
448
+
449
+ // Client mode (or auto mode without productData): Fetch product data
450
+ let isCancelled = false;
451
+
452
+ const loadProduct = async () => {
453
+ setLoading(true);
454
+ setError(null);
455
+
456
+ const baseUrl = effectiveEndpoint;
457
+ const customFetcher = fetcher;
458
+
459
+ try {
460
+ let loadedProduct: CatalogProduct | undefined;
461
+
462
+ if (productId) {
463
+ loadedProduct = await getProduct(productId, {
464
+ baseUrl,
465
+ fetcher: customFetcher as any,
466
+ });
467
+ } else {
468
+ const { items } = await listProducts({
469
+ baseUrl,
470
+ fetcher: customFetcher as any,
471
+ });
472
+ loadedProduct = items?.[0];
473
+ }
474
+
475
+ if (isCancelled) return;
476
+
477
+ if (loadedProduct) {
478
+ setProduct(loadedProduct);
479
+
480
+ // Apply initial placements if provided and not already set
481
+ if (initialPlacements && productId) {
482
+ setPlacementsById((prev) => {
483
+ if (!prev[productId]) {
484
+ return { ...prev, [productId]: initialPlacements };
485
+ }
486
+ return prev;
487
+ });
488
+ }
489
+
490
+ // Derive initial selection
491
+ const attrs = toOptionAttributes(loadedProduct);
492
+ const combos = toCombinations(loadedProduct);
493
+ const defaultSelection =
494
+ initialSelection || deriveDefaultSelection(attrs, combos);
495
+
496
+ // Initialize default colors for color placements
497
+ const colorPlacementDefaults: Record<string, string> = {};
498
+ if (loadedProduct.placements) {
499
+ loadedProduct.placements.forEach((placement: any) => {
500
+ if (
501
+ placement.type === "color" &&
502
+ !defaultSelection[placement.label]
503
+ ) {
504
+ colorPlacementDefaults[placement.label] = "#000000";
505
+ }
506
+ });
507
+ }
508
+
509
+ const finalSelection = {
510
+ ...defaultSelection,
511
+ ...colorPlacementDefaults,
512
+ };
513
+ setSelection(finalSelection);
514
+ }
515
+ } catch (err) {
516
+ if (isCancelled) return;
517
+ console.error("Failed to load product:", err);
518
+ setError(err);
519
+ } finally {
520
+ if (!isCancelled) {
521
+ setLoading(false);
522
+ }
523
+ }
524
+ };
525
+
526
+ loadProduct();
527
+
528
+ return () => {
529
+ isCancelled = true;
530
+ };
531
+ }, [
532
+ productId,
533
+ productData,
534
+ effectiveEndpoint,
535
+ fetcher,
536
+ initialSelection,
537
+ initialPlacements,
538
+ effectiveMode,
539
+ ]);
540
+
541
+ // Compute derived values
542
+ const optionAttributes = useMemo(
543
+ () => (product ? toOptionAttributes(product) : {}),
544
+ [product]
545
+ );
546
+
547
+ const combinations = useMemo(
548
+ () => (product ? toCombinations(product) : []),
549
+ [product]
550
+ );
551
+
552
+ // Update selection handler - memoized to prevent context re-creation
553
+ const updateSelection = useCallback((newSelection: OptionSelection) => {
554
+ setSelection(prev => {
555
+ const merged = { ...prev, ...newSelection };
556
+ return merged;
557
+ });
558
+ }, []);
559
+
560
+ // Design methods (previously in DesignProvider)
561
+ const setPlacementDesign = useCallback(
562
+ (placement: string, design: Partial<PlacementDesign>) => {
563
+ if (!productId) return;
564
+
565
+ setPlacementsById((prev) => ({
566
+ ...prev,
567
+ [productId]: {
568
+ ...prev[productId],
569
+ [placement]: {
570
+ ...prev[productId]?.[placement],
571
+ ...design,
572
+ } as PlacementDesign,
573
+ },
574
+ }));
575
+
576
+ // If alignment is being set and we have a selected artwork, save it per artwork
577
+ if (design.alignment && selectedArtwork) {
578
+ setAlignmentByArtwork((prev) => ({
579
+ ...prev,
580
+ [productId]: {
581
+ ...prev[productId],
582
+ [placement]: {
583
+ ...prev[productId]?.[placement],
584
+ [selectedArtwork.src]: design.alignment!,
585
+ },
586
+ },
587
+ }));
588
+ }
589
+ },
590
+ [productId, selectedArtwork]
591
+ );
592
+
593
+ const getPlacementDesign = useCallback(
594
+ (placement: string): PlacementDesign | undefined => {
595
+ const design = placements[placement];
596
+ return design;
597
+ },
598
+ [placements]
599
+ );
600
+
601
+ // Use shop context methods if available, otherwise use local methods
602
+ const addArtwork = useCallback(
603
+ (artwork: Artwork) => {
604
+ if (shopContext) {
605
+ shopContext.addArtwork(artwork);
606
+ } else {
607
+ setLocalArtworks((prev) => {
608
+ const exists = prev.some((a) => a.src === artwork.src);
609
+ if (exists) {
610
+ return prev.map((a) => (a.src === artwork.src ? artwork : a));
611
+ }
612
+ return [...prev, artwork];
613
+ });
614
+ }
615
+ },
616
+ [shopContext]
617
+ );
618
+
619
+ const setSelectedArtwork = useCallback(
620
+ (artwork: Artwork | undefined) => {
621
+ if (shopContext) {
622
+ shopContext.setSelectedArtwork(artwork);
623
+ } else {
624
+ setLocalSelectedArtwork(artwork);
625
+ }
626
+ },
627
+ [shopContext]
628
+ );
629
+
630
+ const setSelectedPlacement = useCallback(
631
+ (placement: string) => {
632
+ setLocalSelectedPlacement(placement);
633
+ },
634
+ []
635
+ );
636
+
637
+ // Update placements when selected artwork changes
638
+ useEffect(() => {
639
+ if (selectedArtwork && productId) {
640
+ setPlacementsById((prev) => {
641
+ const currentPlacements = prev[productId] || {};
642
+ const updated = { ...currentPlacements };
643
+ const savedAlignments = alignmentByArtwork[productId] || {};
644
+
645
+ Object.keys(updated).forEach((key) => {
646
+ // Check if we have a saved alignment for this artwork on this placement
647
+ const savedAlignment = savedAlignments[key]?.[selectedArtwork.src];
648
+
649
+ updated[key] = {
650
+ ...updated[key],
651
+ imageUrl: selectedArtwork.src,
652
+ // Restore saved alignment if it exists, otherwise reset to default (center)
653
+ alignment: savedAlignment || "center",
654
+ };
655
+ });
656
+ return {
657
+ ...prev,
658
+ [productId]: updated,
659
+ };
660
+ });
661
+ }
662
+ }, [selectedArtwork, productId, alignmentByArtwork]);
663
+
664
+ const applyArtworkToPlacement = useCallback(
665
+ (placement: string, artwork?: Artwork) => {
666
+ if (artwork) {
667
+ setPlacementDesign(placement, { imageUrl: artwork.src });
668
+ }
669
+ },
670
+ [setPlacementDesign]
671
+ );
672
+
673
+ const setPlacementClipShape = useCallback(
674
+ (placement: string, clipShape: ClipShape) => {
675
+ if (!productId) return;
676
+
677
+ setClipShapesById((prev) => ({
678
+ ...prev,
679
+ [productId]: {
680
+ ...prev[productId],
681
+ [placement]: clipShape,
682
+ },
683
+ }));
684
+ },
685
+ [productId]
686
+ );
687
+
688
+ const getPlacementClipShape = useCallback(
689
+ (placement: string): ClipShape => {
690
+ return clipShapes[placement] || 'rectangle'; // Default to rectangle
691
+ },
692
+ [clipShapes]
693
+ );
694
+
695
+ const setClipShapeInExport = useCallback(
696
+ (placement: string, includeInExport: boolean) => {
697
+ if (!productId) return;
698
+
699
+ setClipShapeInExportById((prev) => ({
700
+ ...prev,
701
+ [productId]: {
702
+ ...prev[productId],
703
+ [placement]: includeInExport,
704
+ },
705
+ }));
706
+ },
707
+ [productId]
708
+ );
709
+
710
+ const getClipShapeInExport = useCallback(
711
+ (placement: string): boolean => {
712
+ return clipShapeInExport[placement] ?? false; // Default to false (don't include in export)
713
+ },
714
+ [clipShapeInExport]
715
+ );
716
+
717
+ // Calculate current price based on selection
718
+ const currentPrice = useMemo(() => {
719
+ if (!product) return undefined;
720
+
721
+ // Use findBestCombination from ui-core for consistent logic
722
+ const bestCombination = findBestCombination(
723
+ selection,
724
+ combinations,
725
+ optionAttributes
726
+ );
727
+
728
+ return bestCombination?.price || product.price;
729
+ }, [product, combinations, selection, optionAttributes]);
730
+
731
+ // Create context value with update method and design functionality
732
+ // MEMOIZED to prevent unnecessary re-renders of context consumers
733
+ // NOTE: Realtime functionality has been moved to RealtimeProvider
734
+ const contextValue = useMemo<ReactProductContext>(() => ({
735
+ product,
736
+ optionAttributes,
737
+ combinations,
738
+ selection,
739
+ loading,
740
+ error,
741
+ updateSelection,
742
+ currentPrice,
743
+
744
+ // Design functionality
745
+ placements,
746
+ artworks,
747
+ selectedArtwork,
748
+ selectedPlacement,
749
+ clipShapes,
750
+ clipShapeInExport,
751
+ setPlacementDesign,
752
+ getPlacementDesign,
753
+ addArtwork,
754
+ setSelectedArtwork,
755
+ setSelectedPlacement,
756
+ applyArtworkToPlacement,
757
+ setPlacementClipShape,
758
+ getPlacementClipShape,
759
+ setClipShapeInExport,
760
+ getClipShapeInExport,
761
+ }), [
762
+ product,
763
+ optionAttributes,
764
+ combinations,
765
+ selection,
766
+ loading,
767
+ error,
768
+ updateSelection,
769
+ currentPrice,
770
+ placements,
771
+ artworks,
772
+ selectedArtwork,
773
+ selectedPlacement,
774
+ clipShapes,
775
+ clipShapeInExport,
776
+ setPlacementDesign,
777
+ getPlacementDesign,
778
+ addArtwork,
779
+ setSelectedArtwork,
780
+ setSelectedPlacement,
781
+ applyArtworkToPlacement,
782
+ setPlacementClipShape,
783
+ getPlacementClipShape,
784
+ setClipShapeInExport,
785
+ getClipShapeInExport,
786
+ ]);
787
+
788
+ // Sync with universal provider
789
+ useEffect(() => {
790
+ if (product) {
791
+ universalProvider.getContext().product = product;
792
+ universalProvider.getContext().optionAttributes = optionAttributes;
793
+ universalProvider.getContext().combinations = combinations;
794
+ universalProvider.getContext().selection = selection;
795
+ }
796
+ }, [product, optionAttributes, combinations, selection, universalProvider]);
797
+
798
+ // Handle loading state
799
+ if (loading && renderLoading) {
800
+ return <>{renderLoading()}</>;
801
+ }
802
+
803
+ // Handle error state
804
+ if (error) {
805
+ if (renderError) {
806
+ return <>{renderError(error)}</>;
807
+ }
808
+ // Default error UI
809
+ return <div className="merch-error">Failed to load product</div>;
810
+ }
811
+
812
+ return (
813
+ <ProductContext.Provider value={contextValue}>
814
+ <div
815
+ className={`merch-product ${className || ""}`}
816
+ data-merch-provider
817
+ >
818
+ {children}
819
+ </div>
820
+ </ProductContext.Provider>
821
+ );
822
+ }
823
+
824
+ // Hook to use product context
825
+ export function useProduct() {
826
+ const context = React.useContext(ProductContext);
827
+ if (!context) {
828
+ throw new Error("useProduct must be used within a Product provider");
829
+ }
830
+ return context;
831
+ }
832
+
833
+ // Optional hook that doesn't throw when not in a Product provider
834
+ export function useProductOptional() {
835
+ return React.useContext(ProductContext);
836
+ }
837
+
838
+ // Hook specifically for design functionality (alias for useProduct for backwards compatibility)
839
+ export function useDesign() {
840
+ const context = React.useContext(ProductContext);
841
+ if (!context) {
842
+ throw new Error("useDesign must be used within a Product provider");
843
+ }
844
+ return context;
845
+ }
846
+
847
+ // Optional design hook that doesn't throw when not in a Product provider
848
+ export function useDesignOptional() {
849
+ return React.useContext(ProductContext);
850
+ }