@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.
Files changed (192) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +18 -4
  3. package/package.json +9 -5
  4. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  5. package/src/components/LoadingOverlayPrism.tsx +251 -0
  6. package/src/composed/AddToCart.tsx +229 -0
  7. package/src/composed/ArtAlignment.tsx +703 -0
  8. package/src/composed/ArtSelector.tsx +290 -0
  9. package/src/composed/ArtworkCustomizer.tsx +212 -0
  10. package/src/composed/CanvasEditor.tsx +79 -0
  11. package/src/composed/ColorPicker.tsx +111 -0
  12. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  13. package/src/composed/HeroProductImage.tsx +1071 -0
  14. package/src/composed/Lightbox.index.ts +2 -0
  15. package/src/composed/Lightbox.tsx +230 -0
  16. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  17. package/src/composed/PlacementTabs.tsx +179 -0
  18. package/src/composed/ProductCard.tsx +298 -0
  19. package/src/composed/ProductGallery.tsx +54 -0
  20. package/src/composed/ProductImage.tsx +129 -0
  21. package/src/composed/ProductList.tsx +147 -0
  22. package/src/composed/ProductOptions.tsx +305 -0
  23. package/src/composed/RealtimeMockup.tsx +121 -0
  24. package/src/composed/TileCount.tsx +348 -0
  25. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  26. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  27. package/src/composed/carousels/index.ts +11 -0
  28. package/src/composed/carousels/types.ts +58 -0
  29. package/src/composed/grids/MasonryGrid.tsx +238 -0
  30. package/src/composed/grids/index.ts +9 -0
  31. package/src/composed/search/CurrentRefinements.tsx +80 -0
  32. package/src/composed/search/Filters.tsx +49 -0
  33. package/src/composed/search/FiltersButton.tsx +57 -0
  34. package/src/composed/search/FiltersDrawer.tsx +375 -0
  35. package/src/composed/search/ProductGrid.tsx +118 -0
  36. package/src/composed/search/ProductHit.tsx +56 -0
  37. package/src/composed/search/SearchBox.tsx +109 -0
  38. package/src/composed/search/SearchProvider.tsx +136 -0
  39. package/src/composed/search/facetConfig.ts +16 -0
  40. package/src/composed/search/index.ts +22 -0
  41. package/src/composed/search/meilisearchAdapter.ts +20 -0
  42. package/src/composed/search/types.ts +22 -0
  43. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  44. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  45. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  46. package/src/composed/zoom/index.ts +12 -0
  47. package/src/composed/zoom/types.ts +12 -0
  48. package/src/design-system/ColorPalette.tsx +126 -0
  49. package/src/design-system/ColorSwatch.tsx +49 -0
  50. package/src/design-system/DesignSystemPage.tsx +130 -0
  51. package/src/design-system/ThemeSwitcher.tsx +181 -0
  52. package/src/design-system/TypographyScale.tsx +106 -0
  53. package/src/design-system/index.ts +5 -0
  54. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  55. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  56. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  57. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  58. package/src/hooks/useBrand.ts +41 -0
  59. package/src/hooks/useCanvasContext.ts +127 -0
  60. package/src/hooks/useDeviceDetection.ts +64 -0
  61. package/src/hooks/useFocusTrap.ts +70 -0
  62. package/src/hooks/useImagePreloader.ts +268 -0
  63. package/src/hooks/useImageTransition.ts +608 -0
  64. package/src/hooks/usePlacementsProcessor.ts +74 -0
  65. package/src/hooks/useProductGallery.ts +193 -0
  66. package/src/hooks/useProductPage.ts +467 -0
  67. package/src/hooks/useRenderGuard.ts +96 -0
  68. package/src/hooks/useScrollDirection.ts +196 -0
  69. package/src/hooks/viewport/index.ts +25 -0
  70. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  71. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  72. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  73. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  74. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  75. package/src/hooks/visibility/index.ts +15 -0
  76. package/src/hooks/visibility/observerPool.ts +150 -0
  77. package/src/index.ts +240 -0
  78. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  79. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  80. package/src/layouts/hero-zoom/index.ts +30 -0
  81. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  82. package/src/layouts/hero-zoom/types.ts +113 -0
  83. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  84. package/src/layouts/index.ts +9 -0
  85. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  86. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  87. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  88. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  89. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  90. package/src/layouts/pdp/index.ts +40 -0
  91. package/src/lib/env.ts +15 -0
  92. package/src/lib/locale.ts +167 -0
  93. package/src/lib/router.tsx +46 -0
  94. package/src/lib/utils.ts +6 -0
  95. package/src/lightbox/README.md +77 -0
  96. package/src/next/index.tsx +26 -0
  97. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  98. package/src/patterns/Product.tsx +850 -0
  99. package/src/patterns/ProductPageProvider.tsx +224 -0
  100. package/src/patterns/RealtimeProvider.tsx +1162 -0
  101. package/src/patterns/ShopProvider.tsx +603 -0
  102. package/src/personalization/PersonalizationBridge.tsx +235 -0
  103. package/src/personalization/PersonalizationContext.ts +29 -0
  104. package/src/personalization/PersonalizationInputs.tsx +110 -0
  105. package/src/personalization/PersonalizationProvider.tsx +407 -0
  106. package/src/personalization/canvas-stub.d.ts +22 -0
  107. package/src/personalization/index.ts +43 -0
  108. package/src/personalization/types.ts +48 -0
  109. package/src/personalization/usePersonalization.ts +32 -0
  110. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  111. package/src/personalization/utils.ts +59 -0
  112. package/src/primitives/BrandLogo.tsx +65 -0
  113. package/src/primitives/BrandName.tsx +51 -0
  114. package/src/primitives/Button.tsx +123 -0
  115. package/src/primitives/ColorSwatch.tsx +221 -0
  116. package/src/primitives/DragHintAnimation.tsx +190 -0
  117. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  118. package/src/primitives/FloatingActionGroup.tsx +176 -0
  119. package/src/primitives/ProductPrice.tsx +171 -0
  120. package/src/primitives/ProgressiveBlur.tsx +295 -0
  121. package/src/primitives/ThemeToggle.tsx +125 -0
  122. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  123. package/src/primitives/accordion.tsx +280 -0
  124. package/src/primitives/badge.tsx +137 -0
  125. package/src/primitives/card.tsx +61 -0
  126. package/src/primitives/checkbox.tsx +56 -0
  127. package/src/primitives/collapsible.tsx +51 -0
  128. package/src/primitives/drawer.tsx +828 -0
  129. package/src/primitives/dropdown-menu.tsx +197 -0
  130. package/src/primitives/fieldset.tsx +73 -0
  131. package/src/primitives/index.ts +138 -0
  132. package/src/primitives/input.tsx +91 -0
  133. package/src/primitives/kbd.tsx +130 -0
  134. package/src/primitives/label.tsx +20 -0
  135. package/src/primitives/link.tsx +182 -0
  136. package/src/primitives/popover.tsx +80 -0
  137. package/src/primitives/radio-group.tsx +79 -0
  138. package/src/primitives/scroll-fade.tsx +159 -0
  139. package/src/primitives/select.tsx +170 -0
  140. package/src/primitives/separator.tsx +25 -0
  141. package/src/primitives/slider.tsx +221 -0
  142. package/src/primitives/spinner.tsx +72 -0
  143. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  144. package/src/primitives/stories/Badge.stories.tsx +221 -0
  145. package/src/primitives/stories/Button.stories.tsx +185 -0
  146. package/src/primitives/stories/Card.stories.tsx +171 -0
  147. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  148. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  149. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  150. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  151. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  152. package/src/primitives/stories/Input.stories.tsx +172 -0
  153. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  154. package/src/primitives/stories/Label.stories.tsx +98 -0
  155. package/src/primitives/stories/Link.stories.tsx +260 -0
  156. package/src/primitives/stories/Popover.stories.tsx +178 -0
  157. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  158. package/src/primitives/stories/Select.stories.tsx +222 -0
  159. package/src/primitives/stories/Separator.stories.tsx +134 -0
  160. package/src/primitives/stories/Slider.stories.tsx +203 -0
  161. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  162. package/src/primitives/stories/Surface.stories.tsx +257 -0
  163. package/src/primitives/stories/Switch.stories.tsx +131 -0
  164. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  165. package/src/primitives/stories/TextField.stories.tsx +139 -0
  166. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  167. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  168. package/src/primitives/surface.tsx +86 -0
  169. package/src/primitives/switch.tsx +35 -0
  170. package/src/primitives/tabs.tsx +206 -0
  171. package/src/primitives/text-field.tsx +84 -0
  172. package/src/primitives/textarea.tsx +50 -0
  173. package/src/primitives/tooltip.tsx +58 -0
  174. package/src/services/CanvasExportService.ts +518 -0
  175. package/src/styles/base.css +380 -0
  176. package/src/styles/defaults.css +280 -0
  177. package/src/styles/globals.css +1242 -0
  178. package/src/styles/index.css +17 -0
  179. package/src/styles/ne-themes.css +4740 -0
  180. package/src/styles/tailwind.css +11 -0
  181. package/src/styles/tokens.css +117 -0
  182. package/src/styles/utilities.css +188 -0
  183. package/src/themes/apply-theme.ts +449 -0
  184. package/src/themes/getThemeStyles.ts +454 -0
  185. package/src/themes/index.ts +48 -0
  186. package/src/themes/oklch-theme.ts +283 -0
  187. package/src/themes/presets.ts +989 -0
  188. package/src/themes/types.ts +386 -0
  189. package/src/themes/useTheme.tsx +450 -0
  190. package/src/utils/dev-warnings.ts +161 -0
  191. package/src/utils/devWarnings.ts +153 -0
  192. package/dist/styles.css +0 -1
@@ -0,0 +1,290 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState, useCallback } from "react";
4
+ import { useDesignOptional, type Artwork } from "../patterns/Product";
5
+ import { useShopOptional } from "../patterns/ShopProvider";
6
+ import { checkProviders } from "../utils/devWarnings";
7
+
8
+ // Default artwork sources (dimensions will be loaded dynamically)
9
+ const DEFAULT_ARTWORK_SOURCES = [
10
+ {
11
+ id: "cool-cat",
12
+ name: "Cool Cat",
13
+ src: "https://images.unsplash.com/photo-1533738363-b7f9aef128ce?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
14
+ },
15
+ {
16
+ id: "abstract-art",
17
+ name: "Abstract Art",
18
+ src: "https://images.unsplash.com/photo-1558865869-c93f6f8482af?q=80&w=2940&auto=format&fit=crop",
19
+ },
20
+ {
21
+ id: "mountain-landscape",
22
+ name: "Mountain Landscape",
23
+ src: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?q=80&w=2940&auto=format&fit=crop",
24
+ },
25
+ {
26
+ id: "geometric-pattern",
27
+ name: "Geometric Pattern",
28
+ src: "https://images.unsplash.com/photo-1557672172-298e090bd0f1?q=80&w=2940&auto=format&fit=crop",
29
+ },
30
+ ];
31
+
32
+ export interface ArtSelectorProps {
33
+ className?: string;
34
+ artworks: string[] | Artwork[];
35
+ }
36
+
37
+ /**
38
+ * ArtSelector - Interactive artwork selection gallery
39
+ *
40
+ * ⚠️ **IMPORTANT:** This component renders visible UI (a scrollable gallery of thumbnails).
41
+ * For custom artwork selection UI, use the `useShop()` hook instead.
42
+ *
43
+ * @see https://developers.snowcone.app/docs/react/components/select-artwork
44
+ *
45
+ * A composed component that displays a scrollable gallery of artwork thumbnails
46
+ * with automatic dimension loading and selection state management. Works with
47
+ * both Shop and Product contexts.
48
+ *
49
+ * **🎯 Primary Purpose:**
50
+ * This component sets the `selectedArtwork` in Design context, which is then
51
+ * automatically read by components like `<ProductImage>` and `<ArtworkCustomizer>`.
52
+ *
53
+ * **Features:**
54
+ * - Renders a horizontal scrollable gallery of artwork thumbnails
55
+ * - Automatic image dimension detection on mount
56
+ * - Aspect ratio preservation in thumbnails (60px square)
57
+ * - Selection state with visual feedback (brightness filter)
58
+ * - Keyboard navigation support (Enter/Space to select)
59
+ * - Theme-aware borders using CSS variables
60
+ * - Checkerboard background pattern for transparent images
61
+ * - Focus visible ring on keyboard navigation
62
+ *
63
+ * **Context Integration:**
64
+ * - Works with `Shop` context for global artwork management
65
+ * - Also works with `Product`/`Design` context for product-specific artwork
66
+ * - **Automatically updates `selectedArtwork` in context on click**
67
+ * - Loads artwork dimensions asynchronously on mount
68
+ * - Auto-selects first artwork if none selected
69
+ * - Other components like `ProductImage` and `ArtworkCustomizer` read from this context
70
+ *
71
+ * **Visual Behavior:**
72
+ * - Selected artwork: Full brightness with accent border
73
+ * - Unselected artwork: 50% brightness, 80% on hover
74
+ * - Loading state: Shows "Loading artwork..." text
75
+ *
76
+ * @example
77
+ * ```tsx
78
+ * // Basic usage with Shop context
79
+ * <Shop>
80
+ * <ArtSelector artworks={[
81
+ * 'https://example.com/art1.jpg',
82
+ * 'https://example.com/art2.jpg',
83
+ * 'https://example.com/art3.jpg'
84
+ * ]} />
85
+ * <Product productId="shirt-123">
86
+ * <ProductImage />
87
+ * </Product>
88
+ * </Shop>
89
+ * ```
90
+ *
91
+ * @example
92
+ * ```tsx
93
+ * // Within Product context with ArtworkCustomizer
94
+ * <Product productId="shirt-123">
95
+ * <ArtSelector artworks={[
96
+ * { type: 'regular', src: 'https://example.com/photo.jpg' },
97
+ * { type: 'pattern', src: 'https://example.com/pattern.jpg', tileCount: 1 }
98
+ * ]} />
99
+ * <ProductImage />
100
+ * <ArtworkCustomizer />
101
+ * </Product>
102
+ * ```
103
+ *
104
+ * @example
105
+ * ```tsx
106
+ * // Custom artwork selector using useShop() hook (recommended for custom UI)
107
+ * function MyCustomSelector() {
108
+ * const { setSelectedArtwork } = useShop();
109
+ *
110
+ * const selectArtwork = async (url: string) => {
111
+ * const img = new Image();
112
+ * img.onload = () => {
113
+ * setSelectedArtwork({
114
+ * type: 'regular',
115
+ * src: url,
116
+ * width: img.naturalWidth,
117
+ * height: img.naturalHeight,
118
+ * aspectRatio: img.naturalWidth / img.naturalHeight,
119
+ * });
120
+ * };
121
+ * img.src = url;
122
+ * };
123
+ *
124
+ * return <button onClick={() => selectArtwork(url)}>Select</button>;
125
+ * }
126
+ * ```
127
+ *
128
+ * @param artworks - Array of artwork URLs (strings) or Artwork objects ({ type: 'regular' | 'pattern', src: string, tileCount?: number }). Note: use "artworks" (plural), not "artwork" (singular).
129
+ * @param className - Additional CSS classes for the container element
130
+ */
131
+ export function ArtSelector({
132
+ className,
133
+ artworks: customArtworkUrls,
134
+ }: ArtSelectorProps) {
135
+ // Try to use Shop context first, fallback to Product context
136
+ const shopContext = useShopOptional();
137
+ const productContext = useDesignOptional();
138
+
139
+ // Use whichever context is available
140
+ const context = shopContext || productContext;
141
+
142
+ // Dev-mode warning for missing providers
143
+ useEffect(() => {
144
+ checkProviders(
145
+ "ArtSelector",
146
+ {
147
+ shop: shopContext,
148
+ product: productContext,
149
+ },
150
+ "/components/art-selector"
151
+ );
152
+ }, []);
153
+
154
+ if (!context) {
155
+ throw new Error("ArtSelector must be used within either a Shop or Product provider");
156
+ }
157
+
158
+ // Use ref to track if we've initialized
159
+ const hasInitialized = useRef(false);
160
+
161
+ // Load artwork dimensions on mount
162
+ useEffect(() => {
163
+ // Only load once
164
+ if (hasInitialized.current) return;
165
+ hasInitialized.current = true;
166
+
167
+ const loadArtworkDimensions = async () => {
168
+ const loadedArtworks: Artwork[] = [];
169
+
170
+ // Handle both string URLs and Artwork objects
171
+ const artworkInputs = customArtworkUrls.map((item, index) => {
172
+ if (typeof item === 'string') {
173
+ // String URL - default to regular artwork
174
+ return { type: 'regular' as const, src: item };
175
+ }
176
+ // Already an Artwork object
177
+ return item;
178
+ });
179
+
180
+ for (const artworkInput of artworkInputs) {
181
+ // Check if already exists before loading
182
+ const existingArtwork = context.artworks.find(
183
+ (a: Artwork) => a.src === artworkInput.src
184
+ );
185
+ if (existingArtwork) {
186
+ loadedArtworks.push(existingArtwork);
187
+ continue;
188
+ }
189
+
190
+ const img = new Image();
191
+ img.crossOrigin = 'anonymous';
192
+ img.src = artworkInput.src;
193
+
194
+ await new Promise((resolve) => {
195
+ img.onload = () => {
196
+ // Use the provided artwork object directly
197
+ loadedArtworks.push(artworkInput);
198
+ context.addArtwork(artworkInput);
199
+ resolve(true);
200
+ };
201
+
202
+ img.onerror = () => {
203
+ console.error(`Failed to load image: ${artworkInput.src}`);
204
+ resolve(false);
205
+ };
206
+ });
207
+ }
208
+
209
+ // Set first artwork as selected if none selected
210
+ if (!context.selectedArtwork && loadedArtworks.length > 0) {
211
+ context.setSelectedArtwork(loadedArtworks[0]);
212
+ }
213
+ };
214
+
215
+ loadArtworkDimensions();
216
+ // Empty dependency array - only run once on mount
217
+ }, []);
218
+
219
+ const handleArtworkSelect = useCallback((artwork: Artwork) => {
220
+ context.setSelectedArtwork(artwork);
221
+ }, [context]);
222
+
223
+ return (
224
+ <div className={className}>
225
+ <div className="overflow-x-auto p-[6px]">
226
+ <div className="flex gap-3" style={{ minWidth: "min-content" }}>
227
+ {context.artworks.length === 0 ? (
228
+ <div className="text-sm text-muted-foreground">Loading artwork...</div>
229
+ ) : null}
230
+ {context.artworks.map((artwork: Artwork) => {
231
+ // Fixed dimensions for thumbnails
232
+ const imageHeight = 60;
233
+ const imageWidth = imageHeight; // Square thumbnails
234
+
235
+ const isSelected = context.selectedArtwork?.src === artwork.src;
236
+
237
+ return (
238
+ <button
239
+ key={artwork.src}
240
+ type="button"
241
+ onClick={() => handleArtworkSelect(artwork)}
242
+ onKeyDown={(e) => {
243
+ if (e.key === 'Enter' || e.key === ' ') {
244
+ e.preventDefault();
245
+ handleArtworkSelect(artwork);
246
+ }
247
+ }}
248
+ className="relative flex-shrink-0 cursor-pointer group border-0 p-0 bg-transparent focus:outline-none rounded-[calc(var(--radius-image,4px)+3px)]"
249
+ style={{
250
+ width: `${imageWidth}px`,
251
+ height: `${imageHeight}px`,
252
+ }}
253
+ aria-label={`Select artwork`}
254
+ aria-pressed={isSelected}
255
+ >
256
+ {/* Selection ring - visible border around selected artwork */}
257
+ <div
258
+ className="absolute -inset-[5px] pointer-events-none opacity-0 group-[:focus-visible]:opacity-100 transition-opacity"
259
+ style={{
260
+ border: "2px solid var(--color-accent, hsl(var(--primary)))",
261
+ borderRadius: "calc(var(--radius-image, 4px) + 5px)",
262
+ opacity: isSelected ? 1 : undefined,
263
+ }}
264
+ />
265
+ <span
266
+ className={`absolute inset-0 ${
267
+ isSelected
268
+ ? "brightness-100"
269
+ : "brightness-50 hover:brightness-80"
270
+ } block bg-cover bg-center transition-[filter] duration-150 overflow-hidden`}
271
+ style={{
272
+ backgroundColor: "#fff",
273
+ backgroundPosition: "center, 0 0, 10px 10px",
274
+ backgroundImage: `
275
+ url(${artwork.src}),
276
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
277
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5)
278
+ `,
279
+ backgroundSize: `cover, 20px 20px, 20px 20px`,
280
+ borderRadius: "var(--radius-image, 4px)",
281
+ }}
282
+ />
283
+ </button>
284
+ );
285
+ })}
286
+ </div>
287
+ </div>
288
+ </div>
289
+ );
290
+ }
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { ArtAlignment, type ArtAlignmentProps } from "./ArtAlignment";
5
+ import { TileCount } from "./TileCount";
6
+ import { ColorPicker } from "./ColorPicker";
7
+ import type { Artwork, RegularArtwork, SeamlessPattern } from "./ProductImage";
8
+ import { useProduct, useDesign } from "../patterns/Product";
9
+
10
+ /**
11
+ * Props for ArtworkCustomizer component
12
+ */
13
+ export type ArtworkCustomizerProps = {
14
+ /** Optional artwork to customize (for backwards compatibility). If not provided, reads from Design context. */
15
+ artwork?: Artwork;
16
+ height?: string | number;
17
+ maxHeight?: string | number;
18
+ minHeight?: string | number;
19
+ className?: string;
20
+ /** Callback when image gallery button is clicked */
21
+ onOpenImageGallery?: () => void;
22
+ /** Callback when typography button is clicked */
23
+ onOpenTypography?: () => void;
24
+ /** Optional canvas editor component to render inside CanvasEditor */
25
+ children?: React.ReactNode;
26
+ /** Controlled mode: selected placement */
27
+ selectedPlacement?: string;
28
+ /** Controlled mode: callback when placement changes */
29
+ onPlacementChange?: (placement: string) => void;
30
+ /** Display mode - determines spacing between elements */
31
+ mode?: "canvas" | "alignment";
32
+ /** Editor mode for switching between simple and advanced editing */
33
+ editorMode?: "alignment" | "editor";
34
+ /** Callback when editor mode should change */
35
+ onEditorModeChange?: (mode: "alignment" | "editor") => void;
36
+ };
37
+
38
+ /**
39
+ * ArtworkCustomizer - Smart component that automatically renders the correct customization UI
40
+ *
41
+ * ⚠️ **IMPORTANT: This component reads artwork from Design context, not from props!**
42
+ *
43
+ * This component intelligently chooses between ArtAlignment (for regular artwork) and
44
+ * TileCount (for seamless patterns) based on the artwork type from context. This eliminates
45
+ * the need for manual conditional rendering and prevents mistakes.
46
+ *
47
+ * **🎯 How It Works:**
48
+ * 1. Use `<ArtSelector>` to set artwork in the Design context
49
+ * 2. `<ArtworkCustomizer>` automatically reads from context and renders the right UI
50
+ * 3. Changes to artwork selection automatically update the customizer UI
51
+ *
52
+ * **🚀 Automatic Behavior:**
53
+ * - `artwork.type === 'regular'` → Renders `<ArtAlignment />` with positioning controls
54
+ * - `artwork.type === 'pattern'` → Renders `<TileCount />` with tile density slider
55
+ * - No manual if/else needed!
56
+ * - Seamlessly switches when user selects different artwork types
57
+ *
58
+ * **✅ Type Safety Benefits:**
59
+ * - TypeScript enforces correct artwork types in context
60
+ * - Impossible to show both ArtAlignment and TileCount simultaneously
61
+ * - Perfect for LLMs - single component, context-based API
62
+ *
63
+ * @example
64
+ * ```tsx
65
+ * // CORRECT: Use with ArtSelector to manage artwork via context
66
+ * import { Product, ArtSelector, ArtworkCustomizer } from '@snowcone-app/ui';
67
+ *
68
+ * <Product productId="BEEB77">
69
+ * <ArtSelector artworks={[
70
+ * { type: 'regular', src: 'https://example.com/photo.jpg' },
71
+ * { type: 'pattern', src: 'https://example.com/pattern.jpg', tileCount: 1 }
72
+ * ]} />
73
+ * <ArtworkCustomizer maxHeight={200} />
74
+ * </Product>
75
+ * ```
76
+ *
77
+ * @example
78
+ * ```tsx
79
+ * // CORRECT: Full example with ProductImage
80
+ * import { Product, ProductImage, ArtSelector, ArtworkCustomizer } from '@snowcone-app/ui';
81
+ *
82
+ * <Product productId="BEEB77">
83
+ * <div className="grid grid-cols-2 gap-8">
84
+ * <ProductImage />
85
+ * <div>
86
+ * <ArtSelector artworks={artworkUrls} />
87
+ * <ArtworkCustomizer maxHeight={200} />
88
+ * </div>
89
+ * </div>
90
+ * </Product>
91
+ * ```
92
+ *
93
+ * @example
94
+ * ```tsx
95
+ * // WRONG: Don't try to pass artwork as a prop - it won't work!
96
+ * // ❌ This will NOT work:
97
+ * const artwork = { type: 'regular', src: '...' };
98
+ * <ArtworkCustomizer artwork={artwork} /> // ❌ artwork prop doesn't exist!
99
+ * ```
100
+ *
101
+ * @param maxHeight - (Regular artwork only) Maximum height constraint in pixels (defaults to 120)
102
+ * @param height - (Regular artwork only) Fixed height in pixels
103
+ * @param minHeight - (Regular artwork only) Minimum height constraint in pixels
104
+ * @param className - Additional CSS classes for the component
105
+ */
106
+ export function ArtworkCustomizer(props: ArtworkCustomizerProps) {
107
+ // Get artwork from props (for backwards compatibility) or context
108
+ const designContext = useDesign();
109
+ const artwork = props.artwork || designContext.selectedArtwork;
110
+
111
+ // Try to get product context for placements (only for regular artwork)
112
+ let productCtx: any = null;
113
+ try {
114
+ productCtx = useProduct();
115
+ } catch {
116
+ // Not in a Product context, that's OK
117
+ }
118
+
119
+ // Get placements from product if available
120
+ const placements = productCtx?.product?.placements || [];
121
+
122
+ // Use Design context for placement selection (standardized pattern)
123
+ const selectedPlacement = productCtx?.selectedPlacement;
124
+ const setSelectedPlacement = productCtx?.setSelectedPlacement;
125
+
126
+ // Initialize selectedPlacement to first placement if not set
127
+ useEffect(() => {
128
+ if (placements.length > 0 && !selectedPlacement && setSelectedPlacement) {
129
+ setSelectedPlacement(placements[0].label);
130
+ }
131
+ }, [placements, selectedPlacement, setSelectedPlacement]);
132
+
133
+ // Find the currently selected placement object to check its type
134
+ const currentPlacement = placements.find(
135
+ (p: any) => p.label === selectedPlacement
136
+ );
137
+ const isColorPlacement = currentPlacement?.type === "color";
138
+ const isFitMode = currentPlacement?.defaultScaleMode === "fit";
139
+
140
+ // Debug: Log whenever selectedPlacement changes
141
+ // IMPORTANT: This must be BEFORE early returns to satisfy React's rules of hooks
142
+ useEffect(() => {
143
+ }, [selectedPlacement]);
144
+
145
+ // If no artwork selected, show placeholder
146
+ if (!artwork) {
147
+ return (
148
+ <div className={`text-sm text-muted-foreground ${props.className || ""}`}>
149
+ Select artwork to customize
150
+ </div>
151
+ );
152
+ }
153
+
154
+ // Type guard and render appropriate component
155
+ if (artwork.type === "pattern") {
156
+ return (
157
+ <TileCount
158
+ artwork={artwork}
159
+ imageUrl={artwork.src}
160
+ placement={selectedPlacement} // ← Pass placement for product-specific scoping
161
+ className={props.className}
162
+ />
163
+ );
164
+ }
165
+
166
+ // Detect if we're in canvas editor mode (explicit mode prop or when children are provided without mode)
167
+ const isCanvasEditorMode =
168
+ props.mode === "canvas" || (!!props.children && props.mode !== "alignment");
169
+
170
+ return (
171
+ <div className="flex flex-col gap-4">
172
+ {/* Placement tabs - show ALL placements (image + color) */}
173
+ {placements.length > 1 && (
174
+ <div className="text-sm leading-relaxed">
175
+ {placements.map((p: any) => (
176
+ <button
177
+ key={p.label}
178
+ onClick={() => setSelectedPlacement?.(p.label)}
179
+ className={`inline transition-all duration-200 cursor-pointer mr-3 border-b-2 ${
180
+ selectedPlacement === p.label
181
+ ? "text-foreground border-primary"
182
+ : "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
183
+ }`}
184
+ >
185
+ {p.label}
186
+ </button>
187
+ ))}
188
+ </div>
189
+ )}
190
+
191
+ {/* Conditionally render ColorPicker for color placements or canvas editor for image placements */}
192
+ {isColorPlacement ? (
193
+ <ColorPicker placement={selectedPlacement} />
194
+ ) : isFitMode ? (
195
+ null
196
+ ) : (
197
+ <div className="relative">
198
+ {props.children || (
199
+ <ArtAlignment
200
+ artwork={{ src: artwork.src }}
201
+ placement={selectedPlacement}
202
+ height={props.height}
203
+ maxHeight={props.maxHeight ?? 120}
204
+ minHeight={props.minHeight}
205
+ className={props.className}
206
+ />
207
+ )}
208
+ </div>
209
+ )}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import React, { type ReactNode, type ComponentType } from "react";
4
+ import {
5
+ Image as LucideImage,
6
+ Type as LucideType,
7
+ Shapes as LucideShapes,
8
+ Upload as LucideUpload,
9
+ } from "lucide-react";
10
+
11
+ // Cast to fix React 19 type compatibility with lucide-react
12
+ type IconProps = { className?: string };
13
+ const ImageIcon = LucideImage as ComponentType<IconProps>;
14
+ const TypeIcon = LucideType as ComponentType<IconProps>;
15
+ const ShapesIcon = LucideShapes as ComponentType<IconProps>;
16
+ const UploadIcon = LucideUpload as ComponentType<IconProps>;
17
+
18
+ /**
19
+ * CanvasEditor - Product customization editor
20
+ *
21
+ * This component provides a canvas editing area with toolbar buttons.
22
+ * The actual editor (like Polotno) should be passed as the children prop.
23
+ */
24
+ export function CanvasEditor({
25
+ artworkSrc,
26
+ onOpenImageGallery,
27
+ onOpenTypography,
28
+ children,
29
+ }: {
30
+ artworkSrc?: string;
31
+ onOpenImageGallery?: () => void;
32
+ onOpenTypography?: () => void;
33
+ children?: ReactNode;
34
+ }) {
35
+ return (
36
+ <div className="flex flex-col gap-6">
37
+ {/* Canvas Editor Area */}
38
+ <div className="bg-background border-2 border-border rounded-lg aspect-square overflow-hidden relative">
39
+ {children || (
40
+ <div className="flex items-center justify-center w-full h-full">
41
+ <div className="text-muted-foreground text-sm">No editor provided</div>
42
+ </div>
43
+ )}
44
+ </div>
45
+
46
+ {/* Toolbar */}
47
+ <div className="flex items-center justify-center gap-8 py-2">
48
+ <button
49
+ onClick={onOpenImageGallery}
50
+ className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
51
+ aria-label="Open image gallery"
52
+ >
53
+ <div className="w-10 h-10 flex items-center justify-center">
54
+ <ImageIcon className="w-6 h-6" />
55
+ </div>
56
+ </button>
57
+ <button
58
+ onClick={onOpenTypography}
59
+ className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
60
+ aria-label="Add text"
61
+ >
62
+ <div className="w-10 h-10 flex items-center justify-center">
63
+ <TypeIcon className="w-6 h-6" />
64
+ </div>
65
+ </button>
66
+ <button className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
67
+ <div className="w-10 h-10 flex items-center justify-center">
68
+ <ShapesIcon className="w-6 h-6" />
69
+ </div>
70
+ </button>
71
+ <button className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
72
+ <div className="w-10 h-10 flex items-center justify-center">
73
+ <UploadIcon className="w-6 h-6" />
74
+ </div>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,111 @@
1
+ import React from "react";
2
+ import { useProduct } from "../patterns/Product";
3
+
4
+ export interface ColorPickerProps {
5
+ /** The placement name this color picker controls */
6
+ placement: string;
7
+ /** Optional className for styling */
8
+ className?: string;
9
+ /** Optional default color if no value is set */
10
+ defaultColor?: string;
11
+ }
12
+
13
+ const QUICK_COLORS = [
14
+ "#000000", // Black
15
+ "#FFFFFF", // White
16
+ "#FF0000", // Red
17
+ "#00FF00", // Green
18
+ "#0000FF", // Blue
19
+ "#FFFF00", // Yellow
20
+ "#FF00FF", // Magenta
21
+ "#00FFFF", // Cyan
22
+ "#808080", // Gray
23
+ "#FFA500", // Orange
24
+ "#800080", // Purple
25
+ "#008000", // Dark Green
26
+ ];
27
+
28
+ /**
29
+ * ColorPicker - A color selection component for product placements
30
+ *
31
+ * This component provides an intuitive interface for selecting colors for specific
32
+ * product placements (like Crown, Strap, etc.). It integrates with the Product context
33
+ * to automatically save color selections.
34
+ *
35
+ * **Features:**
36
+ * - Large circular color wheel button for easy color selection
37
+ * - Quick color palette for common colors
38
+ * - Displays current color in hex format
39
+ * - Automatically syncs with Product context
40
+ * - Accessible with proper ARIA labels
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * import { Product, ColorPicker } from '@snowcone-app/ui';
45
+ *
46
+ * <Product productId="AR2P3G">
47
+ * <ColorPicker placement="Crown" defaultColor="#ff0000" />
48
+ * </Product>
49
+ * ```
50
+ */
51
+ export function ColorPicker({
52
+ placement,
53
+ className = "",
54
+ defaultColor = "#000000",
55
+ }: ColorPickerProps) {
56
+ const { selection, updateSelection } = useProduct();
57
+
58
+ // Get current color from selection or use default
59
+ const currentColor = (selection?.[placement] as string) || defaultColor;
60
+
61
+
62
+ const handleColorChange = (color: string) => {
63
+ updateSelection?.({ [placement]: color });
64
+ };
65
+
66
+ const inputId = `color-picker-${placement}`;
67
+
68
+ return (
69
+ <div className={`flex flex-col gap-4 ${className}`}>
70
+ <div className="flex items-center gap-3">
71
+ {/* Color wheel button */}
72
+ <label
73
+ htmlFor={inputId}
74
+ aria-label={`Select color for ${placement}`}
75
+ className="relative h-12 w-12 cursor-pointer rounded-full"
76
+ style={{
77
+ background: `conic-gradient(from 0deg,
78
+ hsl(0, 100%, 50%),
79
+ hsl(30, 100%, 50%),
80
+ hsl(60, 100%, 50%),
81
+ hsl(90, 100%, 50%),
82
+ hsl(120, 100%, 50%),
83
+ hsl(150, 100%, 50%),
84
+ hsl(180, 100%, 50%),
85
+ hsl(210, 100%, 50%),
86
+ hsl(240, 100%, 50%),
87
+ hsl(270, 100%, 50%),
88
+ hsl(300, 100%, 50%),
89
+ hsl(330, 100%, 50%),
90
+ hsl(360, 100%, 50%))`,
91
+ padding: "3px",
92
+ }}
93
+ >
94
+ <div
95
+ className="w-full h-full rounded-full border-2 border-background"
96
+ style={{ backgroundColor: currentColor }}
97
+ />
98
+ </label>
99
+
100
+ {/* Hidden native color input */}
101
+ <input
102
+ id={inputId}
103
+ className="sr-only"
104
+ type="color"
105
+ value={currentColor}
106
+ onChange={(e) => handleColorChange(e.target.value)}
107
+ />
108
+ </div>
109
+ </div>
110
+ );
111
+ }