@snowcone-app/ui 0.1.43 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +18 -4
- package/dist/index.cjs +5 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1079 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,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
|
+
}
|