@snowcone-app/ui 0.1.43 → 0.2.1
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 +32 -0
- package/README.md +18 -4
- package/dist/index.cjs +5 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- 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 +1079 -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,407 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PersonalizationProvider — encapsulates all personalization state, the hidden
|
|
5
|
+
* canvas, and the realtime mockup connection.
|
|
6
|
+
*
|
|
7
|
+
* Just wrap your product page content and pass the design's field definitions
|
|
8
|
+
* and canvas state. Everything else (canvas import, bridge, blob forwarding,
|
|
9
|
+
* realtime connection) is handled automatically.
|
|
10
|
+
*
|
|
11
|
+
* Requires `@snowcone-app/canvas` as a peer dependency (optional — if not
|
|
12
|
+
* installed, the provider works for state management but skips canvas rendering).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <PersonalizationProvider
|
|
17
|
+
* fields={personalization}
|
|
18
|
+
* canvasState={canvasState}
|
|
19
|
+
* canvasImport={() => import("@snowcone-app/canvas")}
|
|
20
|
+
* lazy
|
|
21
|
+
* >
|
|
22
|
+
* <PersonalizationInputs />
|
|
23
|
+
* <MobileProductCarousel ... />
|
|
24
|
+
* </PersonalizationProvider>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, {
|
|
29
|
+
useState,
|
|
30
|
+
useCallback,
|
|
31
|
+
useRef,
|
|
32
|
+
useEffect,
|
|
33
|
+
type ReactNode,
|
|
34
|
+
} from "react";
|
|
35
|
+
import { PersonalizationContext } from "./PersonalizationContext";
|
|
36
|
+
import { useRealtimeOptional } from "../patterns/RealtimeProvider";
|
|
37
|
+
import type {
|
|
38
|
+
PersonalizationField,
|
|
39
|
+
PersonalizationValues,
|
|
40
|
+
} from "./types";
|
|
41
|
+
import { normalizeHex, isValidImageUrl, useDebouncedValue } from "./utils";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Bridge factory — creates the PersonalizationBridge component from the canvas
|
|
45
|
+
// module's hooks at runtime. This avoids a static import of @snowcone-app/canvas
|
|
46
|
+
// in the bundle, since canvas is an optional peer dependency.
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function createBridgeFromCanvas(canvasModule: any): React.ComponentType<{
|
|
50
|
+
fields?: PersonalizationField[];
|
|
51
|
+
values?: PersonalizationValues;
|
|
52
|
+
}> {
|
|
53
|
+
const { useEditor, useCommands, useImageBinding } = canvasModule;
|
|
54
|
+
|
|
55
|
+
function TextBinder({ name, value }: { name: string; value: string }) {
|
|
56
|
+
const { elements } = useEditor();
|
|
57
|
+
const { executeElementUpdate } = useCommands();
|
|
58
|
+
const elementsRef = React.useRef(elements);
|
|
59
|
+
elementsRef.current = elements;
|
|
60
|
+
const executeRef = React.useRef(executeElementUpdate);
|
|
61
|
+
executeRef.current = executeElementUpdate;
|
|
62
|
+
const hasUserInput = React.useRef(false);
|
|
63
|
+
|
|
64
|
+
React.useEffect(() => {
|
|
65
|
+
if (value == null) return;
|
|
66
|
+
if (value === "" && !hasUserInput.current) return;
|
|
67
|
+
hasUserInput.current = true;
|
|
68
|
+
const exec = executeRef.current;
|
|
69
|
+
for (const el of elementsRef.current) {
|
|
70
|
+
if (el.name !== name) continue;
|
|
71
|
+
if (!("setText" in el) || typeof (el as any).setText !== "function") continue;
|
|
72
|
+
const cloned = el.clone();
|
|
73
|
+
(cloned as any).setText(value);
|
|
74
|
+
exec(el, cloned);
|
|
75
|
+
}
|
|
76
|
+
}, [value, name, elements.length]);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ColorBinder({ originalColor, newColor }: { originalColor: string; newColor: string }) {
|
|
81
|
+
const { elements } = useEditor();
|
|
82
|
+
const { executeElementUpdate } = useCommands();
|
|
83
|
+
const elementsRef = React.useRef(elements);
|
|
84
|
+
elementsRef.current = elements;
|
|
85
|
+
const executeRef = React.useRef(executeElementUpdate);
|
|
86
|
+
executeRef.current = executeElementUpdate;
|
|
87
|
+
const appliedColorRef = React.useRef(normalizeHex(originalColor));
|
|
88
|
+
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
const normalizedNew = normalizeHex(newColor);
|
|
91
|
+
if (normalizedNew === appliedColorRef.current) return;
|
|
92
|
+
const matchColor = appliedColorRef.current;
|
|
93
|
+
const exec = executeRef.current;
|
|
94
|
+
for (const el of elementsRef.current) {
|
|
95
|
+
let needsUpdate = false;
|
|
96
|
+
const cloned = el.clone();
|
|
97
|
+
if ("color" in el && typeof (el as any).color === "string") {
|
|
98
|
+
if (normalizeHex((el as any).color) === matchColor) {
|
|
99
|
+
if (typeof (cloned as any).setColor === "function") {
|
|
100
|
+
(cloned as any).setColor(newColor);
|
|
101
|
+
} else {
|
|
102
|
+
(cloned as any).color = newColor;
|
|
103
|
+
}
|
|
104
|
+
needsUpdate = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (normalizeHex((el as any).transformData?.fillColor ?? "") === matchColor) {
|
|
108
|
+
(cloned as any).transformData = { ...(cloned as any).transformData, fillColor: newColor };
|
|
109
|
+
needsUpdate = true;
|
|
110
|
+
}
|
|
111
|
+
if (normalizeHex((el as any).transformData?.strokeColor ?? "") === matchColor) {
|
|
112
|
+
(cloned as any).transformData = { ...(cloned as any).transformData, strokeColor: newColor };
|
|
113
|
+
needsUpdate = true;
|
|
114
|
+
}
|
|
115
|
+
if ((el as any).stroke?.enabled && normalizeHex((el as any).stroke?.color ?? "") === matchColor) {
|
|
116
|
+
(cloned as any).stroke = { ...(cloned as any).stroke, color: newColor };
|
|
117
|
+
needsUpdate = true;
|
|
118
|
+
}
|
|
119
|
+
if (needsUpdate) exec(el, cloned);
|
|
120
|
+
}
|
|
121
|
+
// Only update the tracking ref if elements have loaded.
|
|
122
|
+
// With lazy mounting, the effect can fire before elements exist —
|
|
123
|
+
// keeping the old ref ensures the change retries when elements appear.
|
|
124
|
+
if (elementsRef.current.length > 0) {
|
|
125
|
+
appliedColorRef.current = normalizedNew;
|
|
126
|
+
}
|
|
127
|
+
}, [newColor, elements.length]);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ImageBinder({ name, value, fit = "cover" }: { name: string; value: string; fit?: "cover" | "contain" }) {
|
|
132
|
+
const { setImageUrl } = useImageBinding(name, { fit: fit as any });
|
|
133
|
+
const hasUserInput = React.useRef(false);
|
|
134
|
+
const debouncedValue = useDebouncedValue(value, 500);
|
|
135
|
+
const setImageUrlRef = React.useRef(setImageUrl);
|
|
136
|
+
setImageUrlRef.current = setImageUrl;
|
|
137
|
+
|
|
138
|
+
React.useEffect(() => {
|
|
139
|
+
if (debouncedValue == null) return;
|
|
140
|
+
if (debouncedValue === "" && !hasUserInput.current) return;
|
|
141
|
+
hasUserInput.current = true;
|
|
142
|
+
if (debouncedValue !== "" && !isValidImageUrl(debouncedValue)) return;
|
|
143
|
+
setImageUrlRef.current(debouncedValue);
|
|
144
|
+
}, [debouncedValue]);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return function PersonalizationBridge({ fields = [], values = {} }: { fields?: PersonalizationField[]; values?: PersonalizationValues } = {}) {
|
|
149
|
+
return (
|
|
150
|
+
<>
|
|
151
|
+
{fields.map((field) => {
|
|
152
|
+
if (field.type === "text") {
|
|
153
|
+
return <TextBinder key={`text-${field.name}`} name={field.name} value={values[field.name] ?? ""} />;
|
|
154
|
+
}
|
|
155
|
+
if (field.type === "color") {
|
|
156
|
+
return <ColorBinder key={`color-${field.color}`} originalColor={field.color} newColor={values[field.color] ?? field.color} />;
|
|
157
|
+
}
|
|
158
|
+
if (field.type === "image") {
|
|
159
|
+
return <ImageBinder key={`image-${field.name}`} name={field.name} value={values[field.name] ?? ""} fit={field.fit ?? "cover"} />;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
})}
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Module-level cache — shared across all provider instances
|
|
169
|
+
let cachedCanvasComponent: React.ComponentType<any> | null = null;
|
|
170
|
+
let cachedBridgeComponent: React.ComponentType<any> | null = null;
|
|
171
|
+
let cachedSerialize: ((state: any) => any) | null = null;
|
|
172
|
+
let canvasLoadPromise: Promise<boolean> | null = null;
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Props
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export interface PersonalizationProviderProps {
|
|
179
|
+
/** Personalization field definitions (from design's state.json) */
|
|
180
|
+
fields: PersonalizationField[];
|
|
181
|
+
/** Full canvas state (elements + artboards) for the hidden canvas */
|
|
182
|
+
canvasState?: {
|
|
183
|
+
elements?: any[];
|
|
184
|
+
artboards?: any[];
|
|
185
|
+
activeArtboard?: string;
|
|
186
|
+
} | null;
|
|
187
|
+
/**
|
|
188
|
+
* Lazy import for `@snowcone-app/canvas`. Runs in the consumer's webpack context
|
|
189
|
+
* so module resolution works even though this package doesn't depend on canvas.
|
|
190
|
+
*
|
|
191
|
+
* @example `canvasImport={() => import("@snowcone-app/canvas")}`
|
|
192
|
+
*/
|
|
193
|
+
canvasImport?: () => Promise<any>;
|
|
194
|
+
/** If true, canvas only mounts after first field interaction (saves resources). @default true */
|
|
195
|
+
lazy?: boolean;
|
|
196
|
+
/** Export config overrides */
|
|
197
|
+
exportConfig?: {
|
|
198
|
+
debounceMs?: number;
|
|
199
|
+
maxWaitMs?: number;
|
|
200
|
+
scale?: number;
|
|
201
|
+
maxSize?: number;
|
|
202
|
+
imageFormat?: "png" | "webp";
|
|
203
|
+
imageQuality?: number;
|
|
204
|
+
};
|
|
205
|
+
/** Product placement name used when sending blobs to RealtimeProvider.
|
|
206
|
+
* Defaults to the export artboard name. Override when the product uses
|
|
207
|
+
* a different placement name (e.g. "Main" instead of "Front"). */
|
|
208
|
+
placement?: string;
|
|
209
|
+
/** Initial personalization values (e.g. from a cart item link). */
|
|
210
|
+
initialValues?: Record<string, string>;
|
|
211
|
+
/** Increment to force the hidden canvas to remount with fresh canvasState
|
|
212
|
+
* (e.g. after an editor session modifies element positions). */
|
|
213
|
+
canvasStateVersion?: number;
|
|
214
|
+
children: ReactNode;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Component
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
export function PersonalizationProvider({
|
|
222
|
+
fields,
|
|
223
|
+
canvasState,
|
|
224
|
+
canvasImport,
|
|
225
|
+
lazy = true,
|
|
226
|
+
exportConfig,
|
|
227
|
+
placement: placementOverride,
|
|
228
|
+
initialValues,
|
|
229
|
+
canvasStateVersion,
|
|
230
|
+
children,
|
|
231
|
+
}: PersonalizationProviderProps) {
|
|
232
|
+
const realtime = useRealtimeOptional();
|
|
233
|
+
|
|
234
|
+
// --- State ---
|
|
235
|
+
const [personValues, setPersonValues] = useState<PersonalizationValues>(initialValues ?? {});
|
|
236
|
+
const [isActive, setIsActive] = useState(!!(initialValues && Object.keys(initialValues).length > 0));
|
|
237
|
+
const [isExporting, setIsExporting] = useState(false);
|
|
238
|
+
const [exportedBlobUrls, setExportedBlobUrls] = useState<
|
|
239
|
+
Record<string, string>
|
|
240
|
+
>({});
|
|
241
|
+
const [exportCount, setExportCount] = useState(0);
|
|
242
|
+
const blobUrlsRef = useRef<Record<string, string>>({});
|
|
243
|
+
|
|
244
|
+
// --- Resolve canvas + bridge via the consumer's import thunk ---
|
|
245
|
+
const [CanvasComponent, setCanvasComponent] = useState<React.ComponentType<any> | null>(
|
|
246
|
+
cachedCanvasComponent,
|
|
247
|
+
);
|
|
248
|
+
const [BridgeComponent, setBridgeComponent] = useState<React.ComponentType<any> | null>(
|
|
249
|
+
cachedBridgeComponent,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (CanvasComponent && BridgeComponent) return;
|
|
254
|
+
if (!canvasImport) return;
|
|
255
|
+
|
|
256
|
+
// Reuse in-flight promise if another instance already started loading
|
|
257
|
+
if (!canvasLoadPromise) {
|
|
258
|
+
canvasLoadPromise = canvasImport()
|
|
259
|
+
.then((mod) => {
|
|
260
|
+
cachedCanvasComponent = mod.SnowconeCanvas || mod.default;
|
|
261
|
+
cachedBridgeComponent = createBridgeFromCanvas(mod);
|
|
262
|
+
cachedSerialize = mod.serializeStateForServer || null;
|
|
263
|
+
return true;
|
|
264
|
+
})
|
|
265
|
+
.catch(() => false);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
canvasLoadPromise.then((ok) => {
|
|
269
|
+
if (ok) {
|
|
270
|
+
setCanvasComponent(() => cachedCanvasComponent);
|
|
271
|
+
setBridgeComponent(() => cachedBridgeComponent);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}, [CanvasComponent, BridgeComponent, canvasImport]);
|
|
275
|
+
|
|
276
|
+
// --- Realtime connection (lazy — only on first export) ---
|
|
277
|
+
const realtimeEnabledRef = useRef(false);
|
|
278
|
+
|
|
279
|
+
// --- Cleanup blob URLs on unmount ---
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
return () => {
|
|
282
|
+
for (const url of Object.values(blobUrlsRef.current)) {
|
|
283
|
+
try { URL.revokeObjectURL(url); } catch { /* ignore */ }
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
// --- Actions ---
|
|
289
|
+
|
|
290
|
+
const updateField = useCallback((key: string, value: string) => {
|
|
291
|
+
setIsActive(true);
|
|
292
|
+
setPersonValues((prev) => ({ ...prev, [key]: value }));
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
const reset = useCallback(() => {
|
|
296
|
+
setPersonValues({});
|
|
297
|
+
setIsActive(false);
|
|
298
|
+
setIsExporting(false);
|
|
299
|
+
}, []);
|
|
300
|
+
|
|
301
|
+
const handleExport = useCallback(
|
|
302
|
+
(exports: Record<string, string | Blob>) => {
|
|
303
|
+
// Store blob URLs locally
|
|
304
|
+
const newUrls: Record<string, string> = {};
|
|
305
|
+
for (const [name, data] of Object.entries(exports)) {
|
|
306
|
+
if (blobUrlsRef.current[name]) {
|
|
307
|
+
try { URL.revokeObjectURL(blobUrlsRef.current[name]); } catch { /* ignore */ }
|
|
308
|
+
}
|
|
309
|
+
if (data instanceof Blob) {
|
|
310
|
+
newUrls[name] = URL.createObjectURL(data);
|
|
311
|
+
} else if (typeof data === "string") {
|
|
312
|
+
newUrls[name] = data;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
blobUrlsRef.current = { ...blobUrlsRef.current, ...newUrls };
|
|
316
|
+
setExportedBlobUrls({ ...blobUrlsRef.current });
|
|
317
|
+
setExportCount((c) => c + 1);
|
|
318
|
+
setIsExporting(false);
|
|
319
|
+
|
|
320
|
+
// Forward blobs to RealtimeProvider for server mockup rendering
|
|
321
|
+
if (realtime) {
|
|
322
|
+
if (!realtimeEnabledRef.current) {
|
|
323
|
+
realtimeEnabledRef.current = true;
|
|
324
|
+
realtime.enableRealtime();
|
|
325
|
+
}
|
|
326
|
+
for (const [artboardName, data] of Object.entries(exports)) {
|
|
327
|
+
if (data instanceof Blob) {
|
|
328
|
+
realtime.sendCanvasBlob(placementOverride ?? artboardName, data);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
[realtime, placementOverride],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const handleExportScheduled = useCallback(() => {
|
|
337
|
+
setIsExporting(true);
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
// --- Should the hidden canvas be mounted? ---
|
|
341
|
+
const shouldMountCanvas =
|
|
342
|
+
CanvasComponent &&
|
|
343
|
+
BridgeComponent &&
|
|
344
|
+
canvasState?.elements &&
|
|
345
|
+
canvasState.elements.length > 0 &&
|
|
346
|
+
(!lazy || isActive);
|
|
347
|
+
|
|
348
|
+
// --- Context ---
|
|
349
|
+
const contextValue = React.useMemo(
|
|
350
|
+
() => ({
|
|
351
|
+
fields,
|
|
352
|
+
personValues,
|
|
353
|
+
updateField,
|
|
354
|
+
isActive,
|
|
355
|
+
isExporting,
|
|
356
|
+
exportedBlobUrls,
|
|
357
|
+
exportCount,
|
|
358
|
+
reset,
|
|
359
|
+
}),
|
|
360
|
+
[fields, personValues, updateField, isActive, isExporting, exportedBlobUrls, exportCount, reset],
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<PersonalizationContext.Provider value={contextValue}>
|
|
365
|
+
{shouldMountCanvas && CanvasComponent && BridgeComponent && (
|
|
366
|
+
<div
|
|
367
|
+
key={`person-canvas-${canvasStateVersion ?? 0}`}
|
|
368
|
+
style={{
|
|
369
|
+
position: "fixed",
|
|
370
|
+
left: -9999,
|
|
371
|
+
top: 0,
|
|
372
|
+
width: 500,
|
|
373
|
+
height: 700,
|
|
374
|
+
overflow: "hidden",
|
|
375
|
+
pointerEvents: "none",
|
|
376
|
+
}}
|
|
377
|
+
aria-hidden
|
|
378
|
+
>
|
|
379
|
+
<CanvasComponent
|
|
380
|
+
initialElements={canvasState?.elements}
|
|
381
|
+
artboards={canvasState?.artboards}
|
|
382
|
+
kit="embed-only"
|
|
383
|
+
inheritTheme
|
|
384
|
+
autoExportConfig={{
|
|
385
|
+
enabled: true,
|
|
386
|
+
debounceMs: exportConfig?.debounceMs ?? 100,
|
|
387
|
+
maxWaitMs: exportConfig?.maxWaitMs ?? 1000,
|
|
388
|
+
}}
|
|
389
|
+
autoExportFormat="blob"
|
|
390
|
+
autoExportAll={false}
|
|
391
|
+
exportScale={exportConfig?.scale ?? 1}
|
|
392
|
+
maxExportSize={exportConfig?.maxSize ?? 2000}
|
|
393
|
+
exportImageFormat={exportConfig?.imageFormat ?? "webp"}
|
|
394
|
+
exportImageQuality={exportConfig?.imageQuality ?? 0.85}
|
|
395
|
+
onExport={handleExport}
|
|
396
|
+
onExportScheduled={handleExportScheduled}
|
|
397
|
+
overlay={
|
|
398
|
+
<BridgeComponent fields={fields} values={personValues} />
|
|
399
|
+
}
|
|
400
|
+
/>
|
|
401
|
+
</div>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{children}
|
|
405
|
+
</PersonalizationContext.Provider>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// @snowcone-app/canvas is declared as an OPTIONAL peer dependency in
|
|
2
|
+
// packages/ui's package.json — consumers that need personalization must
|
|
3
|
+
// install it themselves. For typechecking inside this package alone we
|
|
4
|
+
// stub the imports so tsc resolves without pulling the canvas package
|
|
5
|
+
// (which has its own pre-existing typecheck issues and would couple ui's
|
|
6
|
+
// build to canvas's).
|
|
7
|
+
//
|
|
8
|
+
// Runtime behavior is unaffected; consumers that actually use
|
|
9
|
+
// PersonalizationBridge will have @snowcone-app/canvas installed and the
|
|
10
|
+
// real types will win.
|
|
11
|
+
|
|
12
|
+
declare module "@snowcone-app/canvas" {
|
|
13
|
+
export function useEditor(): { elements: any[] };
|
|
14
|
+
export function useCommands(): {
|
|
15
|
+
executeElementUpdate: (oldEl: any, newEl: any) => void;
|
|
16
|
+
};
|
|
17
|
+
export function useImageBinding(
|
|
18
|
+
name: string,
|
|
19
|
+
opts?: { fit?: ImageFitMode }
|
|
20
|
+
): { setImageUrl: (url: string | null) => void };
|
|
21
|
+
export type ImageFitMode = "contain" | "cover" | "fill" | "scale-down";
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personalization — reusable buyer customization system for product designs.
|
|
3
|
+
*
|
|
4
|
+
* Add personalization to any product page:
|
|
5
|
+
*
|
|
6
|
+
* ```tsx
|
|
7
|
+
* <PersonalizationProvider fields={fields} canvasState={canvasState} lazy>
|
|
8
|
+
* <PersonalizationInputs />
|
|
9
|
+
* <YourProductCarousel />
|
|
10
|
+
* </PersonalizationProvider>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Access state from any child component:
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const { personValues, updateField, isExporting, exportedBlobUrls } = usePersonalization();
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Types
|
|
21
|
+
export type {
|
|
22
|
+
PersonalizationField,
|
|
23
|
+
PersonalizationTextField,
|
|
24
|
+
PersonalizationColorField,
|
|
25
|
+
PersonalizationImageField,
|
|
26
|
+
PersonalizationValues,
|
|
27
|
+
} from "./types";
|
|
28
|
+
|
|
29
|
+
// Provider
|
|
30
|
+
export { PersonalizationProvider } from "./PersonalizationProvider";
|
|
31
|
+
|
|
32
|
+
// Hook
|
|
33
|
+
export { usePersonalization, usePersonalizationOptional } from "./usePersonalization";
|
|
34
|
+
|
|
35
|
+
// Input UI
|
|
36
|
+
export { PersonalizationInputs } from "./PersonalizationInputs";
|
|
37
|
+
|
|
38
|
+
// Shimmer — tracks full pipeline, returns { shimmerActive }
|
|
39
|
+
export { usePersonalizationShimmer } from "./usePersonalizationShimmer";
|
|
40
|
+
export type { UsePersonalizationShimmerReturn } from "./usePersonalizationShimmer";
|
|
41
|
+
|
|
42
|
+
// Utilities (for custom input implementations)
|
|
43
|
+
export { scrollInputAboveKeyboard, normalizeHex } from "./utils";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personalization Types
|
|
3
|
+
*
|
|
4
|
+
* Discriminated union for buyer-customizable design fields.
|
|
5
|
+
* Used by PersonalizationProvider, PersonalizationInputs, and PersonalizationBridge.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Text field — buyer can change text on the design */
|
|
9
|
+
export interface PersonalizationTextField {
|
|
10
|
+
type: "text";
|
|
11
|
+
/** Element name in the canvas (matches TextElement.name) */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Buyer-facing label */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Input placeholder */
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Color field — buyer can change a color throughout the design */
|
|
20
|
+
export interface PersonalizationColorField {
|
|
21
|
+
type: "color";
|
|
22
|
+
/** Original hex color to match against canvas elements */
|
|
23
|
+
color: string;
|
|
24
|
+
/** Buyer-facing label */
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Image field — buyer can replace an image in the design */
|
|
29
|
+
export interface PersonalizationImageField {
|
|
30
|
+
type: "image";
|
|
31
|
+
/** Element name in the canvas (matches ImageElement.name) */
|
|
32
|
+
name: string;
|
|
33
|
+
/** Buyer-facing label */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Input placeholder */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** How the image fits in the placement area */
|
|
38
|
+
fit?: "cover" | "contain";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A single personalizable field in a design */
|
|
42
|
+
export type PersonalizationField =
|
|
43
|
+
| PersonalizationTextField
|
|
44
|
+
| PersonalizationColorField
|
|
45
|
+
| PersonalizationImageField;
|
|
46
|
+
|
|
47
|
+
/** Buyer's current personalization values. Keys are field names (text/image) or original hex colors (color). */
|
|
48
|
+
export type PersonalizationValues = Record<string, string>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* usePersonalization — consumer hook for personalization state.
|
|
5
|
+
*
|
|
6
|
+
* Must be used within a <PersonalizationProvider>.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* function MyComponent() {
|
|
11
|
+
* const { personValues, updateField, isExporting, exportedBlobUrls } = usePersonalization();
|
|
12
|
+
* return <input onChange={e => updateField("Name", e.target.value)} />;
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { usePersonalizationContext, type PersonalizationContextValue } from "./PersonalizationContext";
|
|
18
|
+
|
|
19
|
+
export function usePersonalization(): PersonalizationContextValue {
|
|
20
|
+
const ctx = usePersonalizationContext();
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"usePersonalization must be used within a <PersonalizationProvider>"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Optional version that returns null outside provider (for shared components) */
|
|
30
|
+
export function usePersonalizationOptional(): PersonalizationContextValue | null {
|
|
31
|
+
return usePersonalizationContext();
|
|
32
|
+
}
|