@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,298 @@
1
+ import React from "react";
2
+ import { useProduct } from "../patterns/Product";
3
+ import { ProductImage } from "./ProductImage";
4
+ import { ProductPrice } from "../primitives/ProductPrice";
5
+
6
+ export interface ProductCardProps {
7
+ /**
8
+ * Click handler for the card
9
+ */
10
+ onClick?: () => void;
11
+
12
+ /**
13
+ * Additional CSS classes for the card container
14
+ */
15
+ className?: string;
16
+
17
+ /**
18
+ * Card variant - determines the layout and styling
19
+ */
20
+ variant?: "default" | "overlay" | "minimal";
21
+
22
+ /**
23
+ * Whether to show the product price
24
+ */
25
+ showPrice?: boolean;
26
+
27
+ /**
28
+ * Whether to show the product category/tag
29
+ */
30
+ showCategory?: boolean;
31
+
32
+ /**
33
+ * Children to render inside the card (for custom content)
34
+ */
35
+ children?: React.ReactNode;
36
+
37
+ /**
38
+ * The mockup image URL to render. Build it with
39
+ * `getMockupUrl(asset, productCode, { shop })` (ADR-0075) and pass it in.
40
+ * `null`/omitted shows a loading shimmer.
41
+ */
42
+ src?: string | null;
43
+ }
44
+
45
+ const GRADIENT_SCRIM =
46
+ "linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.738) 19%, rgba(0, 0, 0, 0.541) 34%, rgba(0, 0, 0, 0.382) 47%, rgba(0, 0, 0, 0.278) 56.5%, rgba(0, 0, 0, 0.194) 65%, rgba(0, 0, 0, 0.126) 73%, rgba(0, 0, 0, 0.075) 80.2%, rgba(0, 0, 0, 0.042) 86.1%, rgba(0, 0, 0, 0.021) 91%, rgba(0, 0, 0, 0.008) 95.2%, rgba(0, 0, 0, 0.002) 98.2%, transparent 100%)";
47
+
48
+ /**
49
+ * ProductCard - Display product with image, name, price, and category
50
+ *
51
+ * A composed component that renders a product card with multiple layout variants.
52
+ * Integrates with Product context for automatic data binding and supports
53
+ * clickable cards for navigation.
54
+ *
55
+ * Features:
56
+ * - Three distinct variants (default, overlay, minimal)
57
+ * - Optional artwork display via ProductImage
58
+ * - Automatic price formatting
59
+ * - Category/tag display
60
+ * - Clickable card with accessible button semantics
61
+ * - Gradient overlay for text visibility (overlay variant)
62
+ * - Responsive aspect ratios
63
+ * - Dark mode support
64
+ * - CSS custom property theming
65
+ *
66
+ * **Variants:**
67
+ * - `default` - Card with padding, rounded corners, and hover shadow
68
+ * - `overlay` - Image with gradient overlay and text at bottom (search results style)
69
+ * - `minimal` - Clean layout with image and text below (product grid style)
70
+ *
71
+ * **Context Integration:**
72
+ * - Must be used within a `Product` context provider
73
+ * - Automatically extracts product name, price, category from context
74
+ * - ProductImage inside ProductCard reads artwork from Design context (set by ArtSelector)
75
+ * - Works seamlessly with `ProductList` for catalog display
76
+ *
77
+ * @example
78
+ * ```tsx
79
+ * // In a product catalog with ProductList
80
+ * <ProductList limit={6}>
81
+ * <ProductCard
82
+ * variant="overlay"
83
+ * showCategory={true}
84
+ * onClick={() => router.push('/product/${product.id}')}
85
+ * />
86
+ * </ProductList>
87
+ * ```
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * // Standalone with explicit Product context and artwork via ArtSelector
92
+ * <Product productId="shirt-123">
93
+ * <ArtSelector artworks={[...]} />
94
+ * <ProductCard
95
+ * showPrice={true}
96
+ * variant="minimal"
97
+ * />
98
+ * </Product>
99
+ * ```
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * // With custom children for badges or actions
104
+ * <ProductCard variant="default" showPrice>
105
+ * <div className="mt-2">
106
+ * <span className="badge">New</span>
107
+ * <button className="add-to-cart-btn">Quick Add</button>
108
+ * </div>
109
+ * </ProductCard>
110
+ * ```
111
+ *
112
+ * @example
113
+ * ```tsx
114
+ * // Grid layout example
115
+ * <div className="grid grid-cols-3 gap-4">
116
+ * {products.map(product => (
117
+ * <Product key={product.id} productData={product}>
118
+ * <ProductCard variant="minimal" showPrice />
119
+ * </Product>
120
+ * ))}
121
+ * </div>
122
+ * ```
123
+ *
124
+ * @param onClick - Click handler (makes card interactive/clickable)
125
+ * @param className - Additional CSS classes for card container
126
+ * @param variant - Layout variant ("default", "overlay", or "minimal")
127
+ * @param showPrice - Display product price (default: false)
128
+ * @param showCategory - Display product category/tag (default: true)
129
+ * @param children - Custom content to render inside card (badges, buttons, etc.)
130
+ */
131
+ export function ProductCard({
132
+ onClick,
133
+ className = "",
134
+ variant = "default",
135
+ showPrice = false,
136
+ showCategory = true,
137
+ children,
138
+ src = null,
139
+ }: ProductCardProps) {
140
+ const { product } = useProduct();
141
+
142
+ if (!product) {
143
+ return null;
144
+ }
145
+
146
+ const category = product.tags?.[0] || "Product";
147
+ const productName = product.name || "Unnamed Product";
148
+
149
+ // Determine wrapper element and props
150
+ const WrapperElement = onClick ? "button" : "div";
151
+ const wrapperProps = onClick
152
+ ? {
153
+ onClick,
154
+ className: `${className} cursor-pointer text-left`,
155
+ "aria-label": `View ${category} ${productName}`,
156
+ }
157
+ : {
158
+ className,
159
+ };
160
+
161
+ // Variant: Overlay (like search results)
162
+ if (variant === "overlay") {
163
+ // Check if className includes rounded-none to disable border radius
164
+ const hasRoundedNone = className?.includes("rounded-none");
165
+
166
+ return (
167
+ <WrapperElement
168
+ {...wrapperProps}
169
+ className={`w-full bg-background relative group text-left block ${onClick ? "cursor-pointer" : ""} ${className}`}
170
+ style={{ margin: 0, padding: 0, border: 0, lineHeight: 0 }}
171
+ >
172
+ <div
173
+ className={`w-full aspect-2/3 bg-muted relative overflow-hidden hover:bg-muted/80 transition-colors ${hasRoundedNone ? "" : "rounded-image"}`}
174
+ >
175
+ <ProductImage
176
+ src={src}
177
+ alt={productName}
178
+ className={`absolute inset-0 w-full h-full object-cover block ${hasRoundedNone ? "rounded-none" : "rounded-image"}`}
179
+ />
180
+
181
+ {/* Subtle border overlay - right and bottom only for grid layouts */}
182
+ <div
183
+ className="absolute inset-0 pointer-events-none"
184
+ style={{
185
+ boxShadow: 'inset -1px -1px 0 0 rgba(0, 0, 0, 0.1)',
186
+ }}
187
+ aria-hidden="true"
188
+ />
189
+
190
+ {/* Gradient overlay */}
191
+ <div
192
+ className="absolute left-0 right-0 bottom-0 pointer-events-none"
193
+ style={{
194
+ height: "60%",
195
+ background: GRADIENT_SCRIM,
196
+ opacity: 0.85,
197
+ zIndex: 10,
198
+ }}
199
+ aria-hidden="true"
200
+ />
201
+
202
+ {/* Product info overlay */}
203
+ <div
204
+ className="absolute bottom-0 left-0 right-0 p-4 pb-2 z-20 pointer-events-none"
205
+ aria-hidden="true"
206
+ >
207
+ <div className="text-sm text-white font-heading">
208
+ {showCategory && (
209
+ <div className="text-xs font-caption" style={{ color: 'rgba(255, 255, 255, 0.7)' }}>
210
+ {category}
211
+ </div>
212
+ )}
213
+ {productName}
214
+ </div>
215
+ </div>
216
+ </div>
217
+ {children}
218
+ </WrapperElement>
219
+ );
220
+ }
221
+
222
+ // Variant: Minimal (image fills space, text below)
223
+ if (variant === "minimal") {
224
+ // Check if className includes rounded-none to disable border radius
225
+ const hasRoundedNone = className?.includes("rounded-none");
226
+
227
+ return (
228
+ <WrapperElement
229
+ {...wrapperProps}
230
+ className={`w-full bg-background relative group text-left ${onClick ? "cursor-pointer" : ""} ${className}`}
231
+ style={{ margin: 0, padding: 0, border: 0 }}
232
+ >
233
+ <div className="flex flex-col">
234
+ <div
235
+ className={`w-full aspect-2/3 bg-muted relative overflow-hidden hover:bg-muted/80 transition-colors ${hasRoundedNone ? "" : "rounded-image"}`}
236
+ >
237
+ <ProductImage
238
+ src={src}
239
+ alt={productName}
240
+ className={`absolute inset-0 w-full h-full object-cover block ${hasRoundedNone ? "rounded-none" : "rounded-image"}`}
241
+ />
242
+
243
+ {/* Subtle border overlay - right and bottom only for grid layouts */}
244
+ <div
245
+ className="absolute inset-0 pointer-events-none"
246
+ style={{
247
+ boxShadow: 'inset -1px -1px 0 0 rgba(0, 0, 0, 0.1)',
248
+ }}
249
+ aria-hidden="true"
250
+ />
251
+ </div>
252
+
253
+ {/* Product info below image */}
254
+ <div className="p-3">
255
+ {showCategory && (
256
+ <div className="text-xs text-muted-foreground mb-1 font-caption">
257
+ {category}
258
+ </div>
259
+ )}
260
+ <h3 className="font-heading text-sm mb-1 line-clamp-2">
261
+ {productName}
262
+ </h3>
263
+ {showPrice && (
264
+ <ProductPrice className="text-base text-primary" />
265
+ )}
266
+ </div>
267
+ </div>
268
+ {children}
269
+ </WrapperElement>
270
+ );
271
+ }
272
+
273
+ // Variant: Default (card with padding)
274
+ return (
275
+ <WrapperElement
276
+ {...wrapperProps}
277
+ className={`p-4 bg-card text-foreground rounded-card ${onClick ? "hover:shadow-lg transition-shadow cursor-pointer" : ""} ${className}`}
278
+ >
279
+ <div className="aspect-2/3 bg-muted rounded-image overflow-hidden mb-3 relative">
280
+ <ProductImage
281
+ src={src}
282
+ alt={productName}
283
+ className="absolute inset-0 w-full h-full object-cover rounded-image"
284
+ />
285
+ </div>
286
+ <h3 className="font-heading text-sm mb-2 line-clamp-2">
287
+ {productName}
288
+ </h3>
289
+ {showCategory && (
290
+ <div className="text-xs text-muted-foreground mb-2 font-caption">{category}</div>
291
+ )}
292
+ {showPrice && (
293
+ <ProductPrice className="text-base text-primary" />
294
+ )}
295
+ {children}
296
+ </WrapperElement>
297
+ );
298
+ }
@@ -0,0 +1,54 @@
1
+ import React from "react";
2
+ import type { CatalogProduct } from "@snowcone-app/sdk";
3
+ import {
4
+ useProductGallery,
5
+ type UseProductGalleryOptions,
6
+ } from "../hooks/useProductGallery";
7
+
8
+ export interface ProductGalleryProps extends UseProductGalleryOptions {
9
+ productIds?: string[]; // Make optional - when empty/undefined, lists all products
10
+ children: (
11
+ products: CatalogProduct[],
12
+ loading: boolean,
13
+ error: string | null,
14
+ refetch: () => void
15
+ ) => React.ReactNode;
16
+ }
17
+
18
+ /**
19
+ * ProductGallery - Fetch products and provide them via render prop
20
+ *
21
+ * Two modes:
22
+ * 1. Specific products: Pass productIds array
23
+ * 2. List all products: Pass empty array or omit productIds
24
+ *
25
+ * @example Specific products
26
+ * <ProductGallery productIds={["BEEB77", "AR2P3G", "KMYKUK"]}>
27
+ * {(products, loading, error) => (
28
+ * <div className="grid grid-cols-3 gap-4">
29
+ * {products.map(product => (
30
+ * <ProductImage key={product.id} product={product} />
31
+ * ))}
32
+ * </div>
33
+ * )}
34
+ * </ProductGallery>
35
+ *
36
+ * @example List all products
37
+ * <ProductGallery limit={12}>
38
+ * {(products, loading, error) => (
39
+ * <ProductCatalog products={products} />
40
+ * )}
41
+ * </ProductGallery>
42
+ */
43
+ export function ProductGallery({
44
+ productIds = [], // Default to empty array for listing all products
45
+ children,
46
+ ...options
47
+ }: ProductGalleryProps) {
48
+ const { products, loading, error, refetch } = useProductGallery(
49
+ productIds,
50
+ options
51
+ );
52
+
53
+ return <>{children(products, loading, error, refetch)}</>;
54
+ }
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, memo } from "react";
4
+ import type { AspectRatio } from "@snowcone-app/sdk";
5
+
6
+ /**
7
+ * Artwork shape types. Kept here for backwards-compatible imports
8
+ * (`import type { RegularArtwork } from "./ProductImage"`); the canonical
9
+ * definitions also live in `patterns/Product`.
10
+ */
11
+ export type RegularArtwork = {
12
+ type: "regular";
13
+ src: string;
14
+ };
15
+
16
+ export type SeamlessPattern = {
17
+ type: "pattern";
18
+ src: string;
19
+ tileCount: 0.25 | 0.5 | 1 | 2 | 4;
20
+ };
21
+
22
+ export type Artwork = RegularArtwork | SeamlessPattern;
23
+
24
+ export type { AspectRatio };
25
+
26
+ /**
27
+ * ProductImage — a thin `<img>` renderer for a mockup URL.
28
+ *
29
+ * Since ADR-0075 (the image-CDN model), a mockup is a **public URL** you build
30
+ * yourself (e.g. with `getMockupUrl(asset, productCode, { shop })`) and pass in
31
+ * as `src`. This component no longer builds URLs, talks to any context, signs,
32
+ * or queues renders — it only renders, with the loading / fade-in / error
33
+ * niceties you want around a remote image.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * import { getMockupUrl } from "@snowcone-app/sdk";
38
+ * import { ProductImage } from "@snowcone-app/ui";
39
+ *
40
+ * const src = getMockupUrl(artworkUrl, "hoodie-black", { shop: shopId });
41
+ * <ProductImage src={src} alt="Black hoodie with my art" />
42
+ * ```
43
+ *
44
+ * - `src={null}` → loading shimmer (the URL isn't ready yet).
45
+ * - image fades in on load.
46
+ * - `onError` → a quiet error state.
47
+ */
48
+ export interface ProductImageProps {
49
+ /** The mockup image URL. `null` shows a loading shimmer. */
50
+ src: string | null;
51
+ /** Additional CSS classes for the wrapper. */
52
+ className?: string;
53
+ /** Accessible alt text. */
54
+ alt?: string;
55
+ /** Optional intrinsic width hint passed to the `<img>`. */
56
+ width?: number;
57
+ }
58
+
59
+ export const ProductImage = memo(function ProductImage({
60
+ src,
61
+ className,
62
+ alt = "Product preview",
63
+ width,
64
+ }: ProductImageProps) {
65
+ const [loaded, setLoaded] = useState(false);
66
+ const [errored, setErrored] = useState(false);
67
+
68
+ // Reset transition state whenever the source changes.
69
+ useEffect(() => {
70
+ setLoaded(false);
71
+ setErrored(false);
72
+ }, [src]);
73
+
74
+ const wrapperClassName = `relative overflow-hidden ${className || ""}`.trim();
75
+ const showShimmer = src === null || (!loaded && !errored);
76
+
77
+ return (
78
+ <div className={wrapperClassName}>
79
+ {showShimmer && (
80
+ <>
81
+ <div
82
+ className="absolute inset-0 z-10"
83
+ style={{
84
+ background:
85
+ "linear-gradient(90deg, var(--color-muted, #e0e0e0) 25%, var(--color-background, #f5f5f5) 37%, var(--color-muted, #e0e0e0) 63%)",
86
+ backgroundSize: "400% 100%",
87
+ animation: "shimmer 1.2s ease-in-out infinite",
88
+ }}
89
+ role="progressbar"
90
+ aria-label={`Loading ${alt}`}
91
+ aria-valuemin={0}
92
+ aria-valuemax={100}
93
+ />
94
+ <style>{`
95
+ @keyframes shimmer {
96
+ 0% { background-position: 100% 50%; }
97
+ 100% { background-position: 0% 50%; }
98
+ }
99
+ `}</style>
100
+ </>
101
+ )}
102
+
103
+ {errored && (
104
+ <div
105
+ className="absolute inset-0 flex items-center justify-center bg-muted"
106
+ role="alert"
107
+ aria-live="assertive"
108
+ >
109
+ <p className="text-red-500 text-sm">Failed to load image</p>
110
+ </div>
111
+ )}
112
+
113
+ {src !== null && !errored && (
114
+ <img
115
+ alt={alt}
116
+ width={width}
117
+ crossOrigin="anonymous"
118
+ loading="lazy"
119
+ className={`w-full h-full object-cover transition-opacity duration-300 ${
120
+ loaded ? "opacity-100" : "opacity-0"
121
+ }`}
122
+ src={src}
123
+ onLoad={() => setLoaded(true)}
124
+ onError={() => setErrored(true)}
125
+ />
126
+ )}
127
+ </div>
128
+ );
129
+ });
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect } from "react";
4
+ import { Product } from "../patterns/Product";
5
+ import { useShopOptional } from "../patterns/ShopProvider";
6
+ import { listProducts } from "@snowcone-app/sdk";
7
+ import type { CatalogProduct } from "@snowcone-app/sdk";
8
+ import { readEnv } from "../lib/env";
9
+
10
+ export interface ProductListProps {
11
+ endpoint?: string;
12
+ limit?: number;
13
+ children: React.ReactNode;
14
+ /**
15
+ * Additional CSS classes for the grid container
16
+ */
17
+ className?: string;
18
+ /**
19
+ * Grid column classes (Tailwind responsive grid)
20
+ */
21
+ gridClassName?: string;
22
+ }
23
+
24
+ /**
25
+ * ProductList - Fetch and display a grid of products
26
+ *
27
+ * A composed component that efficiently fetches multiple products in a single
28
+ * API call and renders them in a responsive grid. Pre-fetches product data to
29
+ * avoid duplicate API calls from child Product components.
30
+ *
31
+ * Features:
32
+ * - Single API call for multiple products (efficient)
33
+ * - Pre-fetched data passed to Product components
34
+ * - Responsive grid layout (1/2/3 columns)
35
+ * - Loading state with placeholder
36
+ * - Integration with Shop context for configuration
37
+ * - Automatic endpoint inheritance from Shop context
38
+ * - Customizable limit
39
+ *
40
+ * **Performance:**
41
+ * - Fetches all products once (not per-product)
42
+ * - Passes productData to Product to skip individual fetches
43
+ * - Reduces API calls from N to 1
44
+ *
45
+ * **Context Integration:**
46
+ * - Works with Shop context for endpoint configuration
47
+ * - Falls back to props or environment variables
48
+ * - Each child Product gets pre-fetched data
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * // Basic product grid
53
+ * <ProductList limit={12}>
54
+ * <ProductCard variant="minimal" showPrice />
55
+ * </ProductList>
56
+ * ```
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * // With Shop context
61
+ * <Shop endpoint="http://localhost:3000">
62
+ * <ProductList limit={6}>
63
+ * <ProductCard variant="overlay" showCategory />
64
+ * </ProductList>
65
+ * </Shop>
66
+ * ```
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * // Custom children per product
71
+ * <ProductList limit={8}>
72
+ * <div className="space-y-2">
73
+ * <ProductImage />
74
+ * <ProductCard variant="minimal" />
75
+ * <AddToCart className="w-full" />
76
+ * </div>
77
+ * </ProductList>
78
+ * ```
79
+ *
80
+ * @example
81
+ * ```tsx
82
+ * // Standalone with explicit configuration
83
+ * <ProductList
84
+ * endpoint="http://localhost:3000"
85
+ * limit={20}
86
+ * >
87
+ * <ProductCard showPrice showCategory />
88
+ * </ProductList>
89
+ * ```
90
+ *
91
+ * @param endpoint - API endpoint URL (falls back to Shop context or env)
92
+ * @param limit - Maximum number of products to fetch (default: 10)
93
+ * @param children - Component(s) to render for each product (receives Product context)
94
+ */
95
+ export function ProductList({
96
+ endpoint,
97
+ limit = 10,
98
+ children,
99
+ className = "",
100
+ gridClassName = "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
101
+ }: ProductListProps) {
102
+ const [products, setProducts] = useState<CatalogProduct[]>([]);
103
+ const [loading, setLoading] = useState(true);
104
+
105
+ // Get Shop context for configuration
106
+ const shopContext = useShopOptional();
107
+
108
+ // Use shop context values if available, otherwise use props or environment defaults
109
+ const effectiveEndpoint = endpoint ?? shopContext?.endpoint ?? readEnv("NEXT_PUBLIC_MERCH_ENDPOINT");
110
+
111
+ useEffect(() => {
112
+ async function fetchProducts() {
113
+ try {
114
+ const response = await listProducts({
115
+ baseUrl: effectiveEndpoint || "",
116
+ });
117
+ // Apply limit client-side
118
+ const limitedProducts = (response.items || []).slice(0, limit);
119
+ setProducts(limitedProducts);
120
+ setLoading(false);
121
+ } catch (err) {
122
+ console.error("Failed to load products:", err);
123
+ setLoading(false);
124
+ }
125
+ }
126
+
127
+ fetchProducts();
128
+ }, [effectiveEndpoint, limit]);
129
+
130
+ if (loading) {
131
+ return <div>Loading products...</div>;
132
+ }
133
+
134
+ return (
135
+ <div className={`grid ${gridClassName} ${className}`}>
136
+ {products.map((product) => (
137
+ <Product
138
+ key={product.id}
139
+ productId={product.id}
140
+ productData={product}
141
+ >
142
+ {children}
143
+ </Product>
144
+ ))}
145
+ </div>
146
+ );
147
+ }