@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.
Files changed (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. 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
+ }