@snowcone-app/ui 0.1.42 → 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 +33 -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,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
|
+
}
|