@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,348 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useTransition } from "react";
|
|
4
|
+
import * as SliderPrimitive from "@radix-ui/react-slider";
|
|
5
|
+
import type { SeamlessPattern } from "./ProductImage";
|
|
6
|
+
import { useDesignOptional } from "../patterns/Product";
|
|
7
|
+
|
|
8
|
+
interface TileCountProps {
|
|
9
|
+
value?: SeamlessPattern["tileCount"];
|
|
10
|
+
imageUrl?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
valueClassName?: string;
|
|
13
|
+
valueStyle?: React.CSSProperties;
|
|
14
|
+
thumbBorderColor?: string;
|
|
15
|
+
placement?: string; // Optional placement label to update specific placement design
|
|
16
|
+
artwork?: { src: string; tileCount?: SeamlessPattern["tileCount"] }; // Optional artwork prop
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Helper function to convert tile count to descriptive name
|
|
20
|
+
function nameTileDensity(tiles: number): string {
|
|
21
|
+
switch (tiles) {
|
|
22
|
+
case 0.25:
|
|
23
|
+
return "Fewest";
|
|
24
|
+
case 0.5:
|
|
25
|
+
return "Less";
|
|
26
|
+
case 1:
|
|
27
|
+
return "Normal";
|
|
28
|
+
case 2:
|
|
29
|
+
return "More";
|
|
30
|
+
case 4:
|
|
31
|
+
return "Most";
|
|
32
|
+
default:
|
|
33
|
+
return `${tiles}×${tiles}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Helper function for className merging (simplified version)
|
|
38
|
+
function cn(...classes: (string | undefined | false)[]) {
|
|
39
|
+
return classes.filter(Boolean).join(" ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Internal slider component
|
|
43
|
+
const TileCountSlider = React.forwardRef<
|
|
44
|
+
React.ElementRef<typeof SliderPrimitive.Root>,
|
|
45
|
+
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
|
|
46
|
+
thumbBorderColor?: string;
|
|
47
|
+
}
|
|
48
|
+
>(({ className, thumbBorderColor, ...props }, ref) => (
|
|
49
|
+
<SliderPrimitive.Root
|
|
50
|
+
ref={ref}
|
|
51
|
+
className={cn(
|
|
52
|
+
"relative flex w-full touch-none select-none items-center",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-border/30">
|
|
58
|
+
<SliderPrimitive.Range className="absolute h-full" />
|
|
59
|
+
</SliderPrimitive.Track>
|
|
60
|
+
<SliderPrimitive.Thumb
|
|
61
|
+
className="block h-6 w-6 rounded-full bg-background shadow transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 cursor-grab active:cursor-grabbing hover:scale-105"
|
|
62
|
+
style={{
|
|
63
|
+
border: thumbBorderColor ? `2px solid ${thumbBorderColor}` : "2px solid var(--color-primary)",
|
|
64
|
+
}}
|
|
65
|
+
aria-label={props["aria-label"]}
|
|
66
|
+
/>
|
|
67
|
+
</SliderPrimitive.Root>
|
|
68
|
+
));
|
|
69
|
+
|
|
70
|
+
TileCountSlider.displayName = "TileCountSlider";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* TileCount - Interactive tile density control for seamless patterns
|
|
74
|
+
*
|
|
75
|
+
* A composed component that allows users to adjust the tile density (repetition frequency)
|
|
76
|
+
* of seamless pattern artwork. Features a live tiled preview and a slider with 5 preset
|
|
77
|
+
* density levels, making it easy to control how many times a pattern repeats.
|
|
78
|
+
*
|
|
79
|
+
* **🚨 DO NOT USE THIS COMPONENT DIRECTLY!**
|
|
80
|
+
* **Use `<ArtworkCustomizer />` instead - it automatically handles both regular artwork AND patterns.**
|
|
81
|
+
*
|
|
82
|
+
* **Why use ArtworkCustomizer:**
|
|
83
|
+
* - ✅ Single component for both regular artwork and patterns
|
|
84
|
+
* - ✅ TypeScript enforces correct props based on artwork type
|
|
85
|
+
* - ✅ Impossible to make mistakes (no manual conditionals needed)
|
|
86
|
+
* - ✅ Cleaner, simpler code
|
|
87
|
+
*
|
|
88
|
+
* **Only use TileCount directly if:**
|
|
89
|
+
* - You are 100% certain you only have seamless patterns (never regular artwork)
|
|
90
|
+
* - You are building a custom component that wraps TileCount
|
|
91
|
+
* - You have read the ArtworkCustomizer docs and understand why you need direct access
|
|
92
|
+
*
|
|
93
|
+
* **⚠️ CRITICAL: If you use TileCount directly:**
|
|
94
|
+
* - ONLY for seamless pattern artwork - NEVER for regular photos/illustrations
|
|
95
|
+
* - For regular artwork, use `ArtAlignment` component instead
|
|
96
|
+
* - NEVER show both TileCount and ArtAlignment at the same time
|
|
97
|
+
* - Check `artwork.type === 'seamless'` to decide which component to render
|
|
98
|
+
* - Pattern: `{artwork.type === 'seamless' ? <TileCount /> : <ArtAlignment />}`
|
|
99
|
+
*
|
|
100
|
+
* **👉 See ArtworkCustomizer for the recommended approach!**
|
|
101
|
+
*
|
|
102
|
+
* **Features:**
|
|
103
|
+
* - Live preview showing actual tile repetition at 64x64px
|
|
104
|
+
* - 5 preset tile densities (Fewest → Most)
|
|
105
|
+
* - Smooth slider with snap-to-preset behavior
|
|
106
|
+
* - Descriptive labels for each density level
|
|
107
|
+
* - Checkerboard background for transparent patterns
|
|
108
|
+
* - Theme-aware styling with CSS variables
|
|
109
|
+
* - Accessible slider with ARIA labels
|
|
110
|
+
* - Customizable thumb border color
|
|
111
|
+
*
|
|
112
|
+
* **Tile Density Values:**
|
|
113
|
+
* - `0.25` - "Fewest" - Pattern displays at 4x original size (120px tiles in preview)
|
|
114
|
+
* - `0.5` - "Less" - Pattern displays at 2x original size (100px tiles in preview)
|
|
115
|
+
* - `1` - "Normal" - Pattern displays at original size (80px tiles in preview)
|
|
116
|
+
* - `2` - "More" - Pattern displays at 0.5x original size (60px tiles in preview)
|
|
117
|
+
* - `4` - "Most" - Pattern displays at 0.25x original size (44px tiles in preview)
|
|
118
|
+
*
|
|
119
|
+
* **Visual Behavior:**
|
|
120
|
+
* - Preview box: 64x64px with 2px border in border color
|
|
121
|
+
* - Slider: 128px wide with 5 snap points (0, 25, 50, 75, 100)
|
|
122
|
+
* - Thumb: 24x24px with customizable border, hover scale effect
|
|
123
|
+
* - Label: "Tiles" with current density name in muted color
|
|
124
|
+
*
|
|
125
|
+
* **Context Independence:**
|
|
126
|
+
* This is a controlled component that doesn't depend on any context. It simply
|
|
127
|
+
* displays a preview and fires onChange events. Parent components handle state
|
|
128
|
+
* and integration with design/artwork contexts.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```tsx
|
|
132
|
+
* // Basic usage with state
|
|
133
|
+
* const [tileCount, setTileCount] = useState(1);
|
|
134
|
+
*
|
|
135
|
+
* <TileCount
|
|
136
|
+
* value={tileCount}
|
|
137
|
+
* onChange={setTileCount}
|
|
138
|
+
* imageUrl="https://example.com/pattern.png"
|
|
139
|
+
* />
|
|
140
|
+
* ```
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* ```tsx
|
|
144
|
+
* // Custom styling with theme integration
|
|
145
|
+
* <TileCount
|
|
146
|
+
* value={tileDensity}
|
|
147
|
+
* onChange={handleTileChange}
|
|
148
|
+
* imageUrl={patternUrl}
|
|
149
|
+
* className="mb-6"
|
|
150
|
+
* valueClassName="text-primary font-semibold"
|
|
151
|
+
* thumbBorderColor="var(--color-primary)"
|
|
152
|
+
* />
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```tsx
|
|
157
|
+
* // Without preview (slider only)
|
|
158
|
+
* <TileCount
|
|
159
|
+
* value={1}
|
|
160
|
+
* onChange={setTileCount}
|
|
161
|
+
* // No imageUrl = no preview shown
|
|
162
|
+
* />
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @param value - Current tile density (0.25, 0.5, 1, 2, or 4). Defaults to 1 (Normal). If not provided, reads from artwork or Design context
|
|
166
|
+
* @param imageUrl - URL of the seamless pattern image to preview. If omitted, no preview is shown
|
|
167
|
+
* @param className - Additional CSS classes for the container element
|
|
168
|
+
* @param valueClassName - CSS classes for the density label text. Defaults to "text-sm font-normal text-muted-foreground"
|
|
169
|
+
* @param valueStyle - Inline styles for the density label text
|
|
170
|
+
* @param thumbBorderColor - Border color for the slider thumb. Defaults to black (#000)
|
|
171
|
+
*/
|
|
172
|
+
export function TileCount({
|
|
173
|
+
value: propValue,
|
|
174
|
+
imageUrl,
|
|
175
|
+
className,
|
|
176
|
+
valueClassName,
|
|
177
|
+
valueStyle,
|
|
178
|
+
thumbBorderColor,
|
|
179
|
+
placement,
|
|
180
|
+
artwork,
|
|
181
|
+
}: TileCountProps) {
|
|
182
|
+
// Get design context
|
|
183
|
+
const designContext = useDesignOptional();
|
|
184
|
+
|
|
185
|
+
// Get tile count from multiple sources (in order of priority):
|
|
186
|
+
// 1. Explicit value prop (highest priority)
|
|
187
|
+
// 2. Placement design tiles (if placement is specified) ← PLACEMENT-FIRST PRIORITY
|
|
188
|
+
// 3. Selected artwork from context (global fallback)
|
|
189
|
+
// 4. Artwork prop tileCount (initial value fallback)
|
|
190
|
+
// 5. Default to 1
|
|
191
|
+
const placementDesign =
|
|
192
|
+
placement && designContext
|
|
193
|
+
? designContext.getPlacementDesign(placement)
|
|
194
|
+
: undefined;
|
|
195
|
+
|
|
196
|
+
// Only use context tileCount if the context artwork matches our artwork/imageUrl
|
|
197
|
+
// This prevents stale tileCount from previous pattern selection
|
|
198
|
+
const contextArtworkMatches =
|
|
199
|
+
designContext?.selectedArtwork?.src === (artwork?.src || imageUrl);
|
|
200
|
+
|
|
201
|
+
const contextTileCount =
|
|
202
|
+
contextArtworkMatches &&
|
|
203
|
+
designContext?.selectedArtwork?.type === "pattern"
|
|
204
|
+
? designContext.selectedArtwork.tileCount
|
|
205
|
+
: undefined;
|
|
206
|
+
|
|
207
|
+
const sourceValue =
|
|
208
|
+
propValue ??
|
|
209
|
+
placementDesign?.tiles ?? // ← Prioritize placement design (product-specific)
|
|
210
|
+
contextTileCount ?? // ← Fall back to global selectedArtwork
|
|
211
|
+
artwork?.tileCount ??
|
|
212
|
+
1;
|
|
213
|
+
|
|
214
|
+
// Local state for immediate visual updates during drag
|
|
215
|
+
const [optimisticValue, setOptimisticValue] = React.useState<SeamlessPattern["tileCount"]>(sourceValue);
|
|
216
|
+
const [isPending, startTransition] = useTransition();
|
|
217
|
+
|
|
218
|
+
// Sync optimistic value when source changes
|
|
219
|
+
React.useEffect(() => {
|
|
220
|
+
setOptimisticValue(sourceValue);
|
|
221
|
+
}, [sourceValue]);
|
|
222
|
+
|
|
223
|
+
// Use optimistic value for display
|
|
224
|
+
const value = optimisticValue;
|
|
225
|
+
// Determine background size based on tile scale
|
|
226
|
+
// Smaller tile count = larger tiles in preview
|
|
227
|
+
let bgSize = "32px 32px";
|
|
228
|
+
if (value === 0.25) bgSize = "120px 120px";
|
|
229
|
+
else if (value === 0.5) bgSize = "100px 100px";
|
|
230
|
+
else if (value === 1) bgSize = "80px 80px";
|
|
231
|
+
else if (value === 2) bgSize = "60px 60px";
|
|
232
|
+
else if (value === 4) bgSize = "44px 44px";
|
|
233
|
+
|
|
234
|
+
// Convert tile value to slider position (0-100)
|
|
235
|
+
const sliderValue =
|
|
236
|
+
value === 0.25
|
|
237
|
+
? 0
|
|
238
|
+
: value === 0.5
|
|
239
|
+
? 25
|
|
240
|
+
: value === 1
|
|
241
|
+
? 50
|
|
242
|
+
: value === 2
|
|
243
|
+
? 75
|
|
244
|
+
: value === 4
|
|
245
|
+
? 100
|
|
246
|
+
: 50;
|
|
247
|
+
|
|
248
|
+
const handleSliderChange = (values: number[]) => {
|
|
249
|
+
const sliderVal = values[0];
|
|
250
|
+
// Snap to nearest valid tile value
|
|
251
|
+
let newTileCount: SeamlessPattern["tileCount"];
|
|
252
|
+
if (sliderVal < 12.5) {
|
|
253
|
+
newTileCount = 0.25;
|
|
254
|
+
} else if (sliderVal < 37.5) {
|
|
255
|
+
newTileCount = 0.5;
|
|
256
|
+
} else if (sliderVal < 62.5) {
|
|
257
|
+
newTileCount = 1;
|
|
258
|
+
} else if (sliderVal < 87.5) {
|
|
259
|
+
newTileCount = 2;
|
|
260
|
+
} else {
|
|
261
|
+
newTileCount = 4;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (newTileCount !== value) {
|
|
265
|
+
// Immediate optimistic update for responsive UI
|
|
266
|
+
setOptimisticValue(newTileCount);
|
|
267
|
+
|
|
268
|
+
// Defer expensive context updates
|
|
269
|
+
if (designContext) {
|
|
270
|
+
startTransition(() => {
|
|
271
|
+
// PLACEMENT-FIRST PRIORITY (matches ArtAlignment pattern)
|
|
272
|
+
// If we have a placement, update placement design ONLY
|
|
273
|
+
if (placement) {
|
|
274
|
+
designContext.setPlacementDesign(placement, {
|
|
275
|
+
tiles: newTileCount,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// FALLBACK: Update global selectedArtwork only if NO placement specified
|
|
279
|
+
else {
|
|
280
|
+
// Update selected artwork if it's a pattern
|
|
281
|
+
if (designContext.selectedArtwork?.type === "pattern") {
|
|
282
|
+
designContext.setSelectedArtwork({
|
|
283
|
+
...designContext.selectedArtwork,
|
|
284
|
+
tileCount: newTileCount,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// If no selected artwork in context but we have artwork prop, set it
|
|
288
|
+
else if (!designContext.selectedArtwork && artwork) {
|
|
289
|
+
designContext.setSelectedArtwork({
|
|
290
|
+
type: "pattern",
|
|
291
|
+
src: artwork.src,
|
|
292
|
+
tileCount: newTileCount,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className={className}>
|
|
303
|
+
<div className="flex items-center gap-3 text-sm font-semibold text-foreground mb-2 mt-2">
|
|
304
|
+
Tiles
|
|
305
|
+
<span
|
|
306
|
+
className={valueClassName || "text-sm font-normal text-muted-foreground"}
|
|
307
|
+
style={valueStyle}
|
|
308
|
+
>
|
|
309
|
+
{nameTileDensity(value)}
|
|
310
|
+
</span>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="flex items-center gap-4">
|
|
313
|
+
{/* Tile Preview */}
|
|
314
|
+
{imageUrl && (
|
|
315
|
+
<div className="flex-shrink-0 border-2 border-border/30 rounded-sm overflow-hidden">
|
|
316
|
+
<div
|
|
317
|
+
className="h-16 w-16 bg-center bg-repeat"
|
|
318
|
+
style={{
|
|
319
|
+
backgroundColor: '#fff',
|
|
320
|
+
backgroundPosition: 'center, 0 0, 8px 8px',
|
|
321
|
+
backgroundImage: `
|
|
322
|
+
url('${imageUrl}'),
|
|
323
|
+
linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
|
|
324
|
+
linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5)
|
|
325
|
+
`,
|
|
326
|
+
backgroundSize: `${bgSize}, 16px 16px, 16px 16px`,
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
</div>
|
|
330
|
+
)}
|
|
331
|
+
|
|
332
|
+
{/* Slider */}
|
|
333
|
+
<div className="w-32">
|
|
334
|
+
<TileCountSlider
|
|
335
|
+
value={[sliderValue]}
|
|
336
|
+
max={100}
|
|
337
|
+
step={25}
|
|
338
|
+
className="w-full"
|
|
339
|
+
onValueChange={handleSliderChange}
|
|
340
|
+
thumbBorderColor={thumbBorderColor}
|
|
341
|
+
aria-label="Adjust tile density"
|
|
342
|
+
aria-valuetext={`${nameTileDensity(value)} tile density`}
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HeroCarousel - Lightweight carousel optimized for HeroZoomLayout
|
|
5
|
+
*
|
|
6
|
+
* A simplified carousel designed for use within scroll-driven animation contexts.
|
|
7
|
+
* Key optimizations for performance:
|
|
8
|
+
* 1. Uses CSS containment to isolate compositing layers
|
|
9
|
+
* 2. Uses translate3d for GPU-accelerated slides
|
|
10
|
+
* 3. Minimal JavaScript - no transform updates during scroll
|
|
11
|
+
* 4. Swipe-only navigation (no competing scroll handlers)
|
|
12
|
+
* 5. will-change hints for browser optimization
|
|
13
|
+
*
|
|
14
|
+
* Use this carousel when:
|
|
15
|
+
* - You're inside a HeroZoomLayout or similar scroll-driven animation
|
|
16
|
+
* - You need maximum performance with minimal features
|
|
17
|
+
* - You don't need drag feedback during swipe
|
|
18
|
+
*
|
|
19
|
+
* Use MobileProductCarousel when:
|
|
20
|
+
* - You need drag feedback during swipe
|
|
21
|
+
* - You want peek animation for UX education
|
|
22
|
+
* - You need EnhancedImageViewer integration
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <HeroZoomLayout>
|
|
27
|
+
* <HeroCarousel
|
|
28
|
+
* images={heroImages}
|
|
29
|
+
* productId="tshirt-001"
|
|
30
|
+
* currentArtwork={artwork}
|
|
31
|
+
* />
|
|
32
|
+
* </HeroZoomLayout>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
37
|
+
import type { CarouselImage } from "./types";
|
|
38
|
+
import { HeroProductImage } from "../HeroProductImage";
|
|
39
|
+
import type { ArtworkData } from "@snowcone-app/sdk";
|
|
40
|
+
|
|
41
|
+
export interface HeroCarouselProps {
|
|
42
|
+
/** Array of images to display in the carousel */
|
|
43
|
+
images: CarouselImage[];
|
|
44
|
+
|
|
45
|
+
/** Current active slide index (controlled) */
|
|
46
|
+
currentIndex?: number;
|
|
47
|
+
|
|
48
|
+
/** Callback when the active slide changes */
|
|
49
|
+
onIndexChange?: (index: number) => void;
|
|
50
|
+
|
|
51
|
+
/** Additional CSS classes */
|
|
52
|
+
className?: string;
|
|
53
|
+
|
|
54
|
+
/** Current artwork for mockup generation */
|
|
55
|
+
currentArtwork?: ArtworkData;
|
|
56
|
+
|
|
57
|
+
/** Product ID for mockup generation */
|
|
58
|
+
productId?: string;
|
|
59
|
+
|
|
60
|
+
/** Callback when image is tapped */
|
|
61
|
+
onImageTap?: (index: number) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function HeroCarousel({
|
|
65
|
+
images,
|
|
66
|
+
currentIndex = 0,
|
|
67
|
+
onIndexChange,
|
|
68
|
+
className = "",
|
|
69
|
+
currentArtwork,
|
|
70
|
+
productId,
|
|
71
|
+
onImageTap,
|
|
72
|
+
}: HeroCarouselProps) {
|
|
73
|
+
const [activeIndex, setActiveIndex] = useState(currentIndex);
|
|
74
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(
|
|
76
|
+
null
|
|
77
|
+
);
|
|
78
|
+
const isSwipingRef = useRef(false);
|
|
79
|
+
|
|
80
|
+
// Sync with external index changes
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setActiveIndex(currentIndex);
|
|
83
|
+
}, [currentIndex]);
|
|
84
|
+
|
|
85
|
+
const goToSlide = useCallback(
|
|
86
|
+
(index: number) => {
|
|
87
|
+
const clampedIndex = Math.max(0, Math.min(images.length - 1, index));
|
|
88
|
+
setActiveIndex(clampedIndex);
|
|
89
|
+
onIndexChange?.(clampedIndex);
|
|
90
|
+
},
|
|
91
|
+
[images.length, onIndexChange]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Simple touch handler - only horizontal swipes, no drag animation
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const container = containerRef.current;
|
|
97
|
+
if (!container) return;
|
|
98
|
+
|
|
99
|
+
const handleTouchStart = (e: TouchEvent) => {
|
|
100
|
+
const touch = e.touches[0];
|
|
101
|
+
touchStartRef.current = {
|
|
102
|
+
x: touch.clientX,
|
|
103
|
+
y: touch.clientY,
|
|
104
|
+
time: Date.now(),
|
|
105
|
+
};
|
|
106
|
+
isSwipingRef.current = false;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleTouchMove = (e: TouchEvent) => {
|
|
110
|
+
if (!touchStartRef.current) return;
|
|
111
|
+
|
|
112
|
+
const touch = e.touches[0];
|
|
113
|
+
const deltaX = Math.abs(touch.clientX - touchStartRef.current.x);
|
|
114
|
+
const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
|
|
115
|
+
|
|
116
|
+
// If horizontal swipe detected, prevent vertical scroll
|
|
117
|
+
if (deltaX > 10 && deltaX > deltaY * 1.5) {
|
|
118
|
+
isSwipingRef.current = true;
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleTouchEnd = (e: TouchEvent) => {
|
|
124
|
+
if (!touchStartRef.current) return;
|
|
125
|
+
|
|
126
|
+
const touch = e.changedTouches[0];
|
|
127
|
+
const deltaX = touch.clientX - touchStartRef.current.x;
|
|
128
|
+
const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
|
|
129
|
+
const elapsed = Date.now() - touchStartRef.current.time;
|
|
130
|
+
|
|
131
|
+
// Only process horizontal swipes
|
|
132
|
+
if (Math.abs(deltaX) > 50 && Math.abs(deltaX) > deltaY) {
|
|
133
|
+
if (deltaX < 0 && activeIndex < images.length - 1) {
|
|
134
|
+
// Swipe left - next
|
|
135
|
+
goToSlide(activeIndex + 1);
|
|
136
|
+
} else if (deltaX > 0 && activeIndex > 0) {
|
|
137
|
+
// Swipe right - previous
|
|
138
|
+
goToSlide(activeIndex - 1);
|
|
139
|
+
}
|
|
140
|
+
} else if (elapsed < 300 && Math.abs(deltaX) < 10 && deltaY < 10) {
|
|
141
|
+
// Tap - trigger callback
|
|
142
|
+
onImageTap?.(activeIndex);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
touchStartRef.current = null;
|
|
146
|
+
isSwipingRef.current = false;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
container.addEventListener("touchstart", handleTouchStart, {
|
|
150
|
+
passive: true,
|
|
151
|
+
});
|
|
152
|
+
container.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
153
|
+
container.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
container.removeEventListener("touchstart", handleTouchStart);
|
|
157
|
+
container.removeEventListener("touchmove", handleTouchMove);
|
|
158
|
+
container.removeEventListener("touchend", handleTouchEnd);
|
|
159
|
+
};
|
|
160
|
+
}, [activeIndex, images.length, goToSlide, onImageTap]);
|
|
161
|
+
|
|
162
|
+
if (!images || images.length === 0) {
|
|
163
|
+
return <div className="w-full h-full bg-muted" />;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
ref={containerRef}
|
|
169
|
+
className={`relative w-full h-full ${className}`}
|
|
170
|
+
style={{
|
|
171
|
+
// CSS containment - critical for performance with parent scale transform
|
|
172
|
+
contain: "layout paint style",
|
|
173
|
+
overflow: "hidden",
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{/* Slides container */}
|
|
177
|
+
<div
|
|
178
|
+
className="flex h-full transition-transform duration-300 ease-out"
|
|
179
|
+
style={{
|
|
180
|
+
// Use translate3d for GPU compositing layer
|
|
181
|
+
transform: `translate3d(${-activeIndex * 100}%, 0, 0)`,
|
|
182
|
+
willChange: "transform",
|
|
183
|
+
backfaceVisibility: "hidden",
|
|
184
|
+
WebkitBackfaceVisibility: "hidden",
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{images.map((image, index) => (
|
|
188
|
+
<div
|
|
189
|
+
key={index}
|
|
190
|
+
className="w-full h-full flex-shrink-0"
|
|
191
|
+
style={{
|
|
192
|
+
contain: "layout paint",
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
{image.isRealMockup && currentArtwork ? (
|
|
196
|
+
<HeroProductImage
|
|
197
|
+
productId={productId}
|
|
198
|
+
artwork={currentArtwork}
|
|
199
|
+
placement={image.placement}
|
|
200
|
+
mockupId={image.mockupId}
|
|
201
|
+
className="w-full h-full object-cover"
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<img
|
|
205
|
+
src={image.src}
|
|
206
|
+
alt={image.label || `Slide ${index + 1}`}
|
|
207
|
+
crossOrigin="anonymous"
|
|
208
|
+
className="w-full h-full object-cover"
|
|
209
|
+
loading={index <= 1 ? "eager" : "lazy"}
|
|
210
|
+
decoding="async"
|
|
211
|
+
draggable={false}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* Dot indicators */}
|
|
219
|
+
{images.length > 1 && (
|
|
220
|
+
<div
|
|
221
|
+
className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 z-10"
|
|
222
|
+
style={{ contain: "layout style" }}
|
|
223
|
+
>
|
|
224
|
+
{images.map((_, index) => (
|
|
225
|
+
<button
|
|
226
|
+
key={index}
|
|
227
|
+
onClick={() => goToSlide(index)}
|
|
228
|
+
className={`w-2 h-2 rounded-full transition-colors ${
|
|
229
|
+
index === activeIndex ? "bg-white" : "bg-white/50"
|
|
230
|
+
}`}
|
|
231
|
+
aria-label={`Go to slide ${index + 1}`}
|
|
232
|
+
/>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default HeroCarousel;
|