@snowcone-app/ui 0.1.43 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +18 -4
- 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 +1071 -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,375 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRefinementList, useRange, useStats } from "react-instantsearch";
|
|
4
|
+
import { X as LucideX, SlidersHorizontal as LucideSlidersHorizontal } from "lucide-react";
|
|
5
|
+
import { useState, useEffect, type ComponentType } from "react";
|
|
6
|
+
|
|
7
|
+
// Cast to fix React 19 type compatibility with lucide-react
|
|
8
|
+
type IconProps = { className?: string; size?: number };
|
|
9
|
+
const XIcon = LucideX as ComponentType<IconProps>;
|
|
10
|
+
const SlidersHorizontalIcon = LucideSlidersHorizontal as ComponentType<IconProps>;
|
|
11
|
+
import * as Slider from "@radix-ui/react-slider";
|
|
12
|
+
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
13
|
+
|
|
14
|
+
interface FiltersDrawerProps {
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function FiltersDrawer({ isOpen, onClose }: FiltersDrawerProps) {
|
|
20
|
+
const [showCounts, setShowCounts] = useState(false);
|
|
21
|
+
const { nbHits } = useStats();
|
|
22
|
+
const containerRef = useFocusTrap(isOpen, onClose);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (isOpen) {
|
|
26
|
+
document.body.style.overflow = "hidden";
|
|
27
|
+
} else {
|
|
28
|
+
document.body.style.overflow = "";
|
|
29
|
+
}
|
|
30
|
+
return () => {
|
|
31
|
+
document.body.style.overflow = "";
|
|
32
|
+
};
|
|
33
|
+
}, [isOpen]);
|
|
34
|
+
|
|
35
|
+
// Handle Escape key to close drawer
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
38
|
+
if (e.key === "Escape" && isOpen) {
|
|
39
|
+
onClose();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
document.addEventListener("keydown", handleEscape);
|
|
44
|
+
return () => document.removeEventListener("keydown", handleEscape);
|
|
45
|
+
}, [isOpen, onClose]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
{/* Backdrop */}
|
|
50
|
+
<div
|
|
51
|
+
className={`fixed inset-0 bg-black/50 z-40 transition-opacity ${
|
|
52
|
+
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
53
|
+
}`}
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
{/* Drawer Panel - Right Side */}
|
|
59
|
+
<div
|
|
60
|
+
ref={containerRef}
|
|
61
|
+
className={`fixed right-0 top-0 bottom-0 w-[min(400px,90vw)] bg-background/85 backdrop-blur-xs z-50 shadow-xl flex flex-col transform transition-transform ${
|
|
62
|
+
isOpen ? "translate-x-0" : "translate-x-full"
|
|
63
|
+
}`}
|
|
64
|
+
role="dialog"
|
|
65
|
+
aria-modal="true"
|
|
66
|
+
aria-labelledby="filters-drawer-title"
|
|
67
|
+
>
|
|
68
|
+
{/* Header */}
|
|
69
|
+
<div className="flex items-center justify-between px-4 py-4">
|
|
70
|
+
<h2 id="filters-drawer-title" className="text-xl font-bold">Filters</h2>
|
|
71
|
+
<button
|
|
72
|
+
onClick={onClose}
|
|
73
|
+
className="p-2 hover:bg-foreground/5 rounded-full transition-colors"
|
|
74
|
+
aria-label="Close filters"
|
|
75
|
+
>
|
|
76
|
+
<XIcon className="w-5 h-5" />
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Results Count */}
|
|
81
|
+
<div className="px-4 pb-4">
|
|
82
|
+
<p className="text-sm font-caption text-foreground/60" role="status" aria-live="polite">
|
|
83
|
+
{nbHits.toLocaleString()} {nbHits === 1 ? 'result' : 'results'}
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Content */}
|
|
88
|
+
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
|
89
|
+
<div className="flex flex-col gap-8">
|
|
90
|
+
{/* Tags Section */}
|
|
91
|
+
<TagsSection showCounts={showCounts} setShowCounts={setShowCounts} />
|
|
92
|
+
|
|
93
|
+
{/* Price Section */}
|
|
94
|
+
<PriceSection />
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function TagsSection({
|
|
103
|
+
showCounts,
|
|
104
|
+
setShowCounts,
|
|
105
|
+
}: {
|
|
106
|
+
showCounts: boolean;
|
|
107
|
+
setShowCounts: (show: boolean) => void;
|
|
108
|
+
}) {
|
|
109
|
+
const { items, refine } = useRefinementList({
|
|
110
|
+
attribute: "tags",
|
|
111
|
+
sortBy: ["count:desc", "name:asc"],
|
|
112
|
+
limit: 100,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="bg-foreground/5 rounded-lg p-4">
|
|
119
|
+
<button
|
|
120
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
121
|
+
className={`flex items-center justify-between w-full text-left group ${isExpanded ? 'mb-4' : ''}`}
|
|
122
|
+
aria-expanded={isExpanded}
|
|
123
|
+
aria-controls="tags-content"
|
|
124
|
+
>
|
|
125
|
+
<h3 className="text-base font-semibold text-primary">
|
|
126
|
+
Tags
|
|
127
|
+
</h3>
|
|
128
|
+
<svg
|
|
129
|
+
className={`w-5 h-5 transition-transform text-foreground/40 group-hover:text-foreground/60 ${isExpanded ? "rotate-180" : ""}`}
|
|
130
|
+
fill="none"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
stroke="currentColor"
|
|
133
|
+
strokeWidth={2.5}
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
>
|
|
136
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
137
|
+
</svg>
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
{isExpanded && (
|
|
141
|
+
<div className="space-y-4" id="tags-content">
|
|
142
|
+
{/* Tags */}
|
|
143
|
+
<div className="flex flex-wrap gap-2" role="group" aria-label="Product tags filter">
|
|
144
|
+
{items.map((item) => (
|
|
145
|
+
<button
|
|
146
|
+
key={item.value}
|
|
147
|
+
onClick={() => refine(item.value)}
|
|
148
|
+
className={`
|
|
149
|
+
inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors
|
|
150
|
+
${
|
|
151
|
+
item.isRefined
|
|
152
|
+
? "bg-primary text-on-primary"
|
|
153
|
+
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
154
|
+
}
|
|
155
|
+
`}
|
|
156
|
+
aria-label={`${item.isRefined ? 'Remove' : 'Apply'} ${item.label} filter${showCounts ? ` (${item.count} products)` : ''}`}
|
|
157
|
+
aria-pressed={item.isRefined}
|
|
158
|
+
>
|
|
159
|
+
<span aria-hidden="true">{item.label}</span>
|
|
160
|
+
{showCounts && (
|
|
161
|
+
<span className={`text-xs font-caption ${item.isRefined ? "opacity-80" : "opacity-60"}`} aria-hidden="true">
|
|
162
|
+
{item.count}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</button>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Show Counts Toggle */}
|
|
170
|
+
<div className="flex items-center justify-between pt-2">
|
|
171
|
+
<span className="text-sm font-label text-foreground/60" id="show-counts-label">Show counts</span>
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => setShowCounts(!showCounts)}
|
|
174
|
+
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-all ${
|
|
175
|
+
showCounts ? "bg-primary" : "bg-foreground/20"
|
|
176
|
+
}`}
|
|
177
|
+
role="switch"
|
|
178
|
+
aria-checked={showCounts}
|
|
179
|
+
aria-labelledby="show-counts-label"
|
|
180
|
+
>
|
|
181
|
+
<span
|
|
182
|
+
className={`inline-block h-4 w-4 transform rounded-full bg-background shadow-sm transition-transform ${
|
|
183
|
+
showCounts ? "translate-x-6" : "translate-x-1"
|
|
184
|
+
}`}
|
|
185
|
+
aria-hidden="true"
|
|
186
|
+
/>
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function PriceSection() {
|
|
196
|
+
const { range, refine, start, canRefine } = useRange({ attribute: "price" });
|
|
197
|
+
|
|
198
|
+
const rangeMin = typeof range.min === "number" && isFinite(range.min) ? range.min : 0;
|
|
199
|
+
const rangeMax = typeof range.max === "number" && isFinite(range.max) ? range.max : 15000;
|
|
200
|
+
|
|
201
|
+
const [localRange, setLocalRange] = useState<[number, number]>([
|
|
202
|
+
typeof start?.[0] === "number" && isFinite(start[0]) ? start[0] : rangeMin,
|
|
203
|
+
typeof start?.[1] === "number" && isFinite(start[1]) ? start[1] : rangeMax,
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (start && Array.isArray(start)) {
|
|
208
|
+
setLocalRange([
|
|
209
|
+
typeof start[0] === "number" && isFinite(start[0]) ? start[0] : rangeMin,
|
|
210
|
+
typeof start[1] === "number" && isFinite(start[1]) ? start[1] : rangeMax,
|
|
211
|
+
]);
|
|
212
|
+
} else {
|
|
213
|
+
// Initialize with range min/max when start is not available
|
|
214
|
+
setLocalRange([rangeMin, rangeMax]);
|
|
215
|
+
}
|
|
216
|
+
}, [start, rangeMin, rangeMax]);
|
|
217
|
+
|
|
218
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
219
|
+
|
|
220
|
+
if (!canRefine) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const formatPrice = (price: number) => {
|
|
225
|
+
if (!isFinite(price)) return "$0";
|
|
226
|
+
const priceInDollars = price / 100;
|
|
227
|
+
if (priceInDollars >= 100) {
|
|
228
|
+
return `$${(priceInDollars / 1000).toFixed(1)}k`;
|
|
229
|
+
} else if (priceInDollars >= 10) {
|
|
230
|
+
return `$${priceInDollars.toFixed(0)}`;
|
|
231
|
+
} else {
|
|
232
|
+
return `$${priceInDollars.toFixed(2)}`;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div className="bg-foreground/5 rounded-lg p-4">
|
|
238
|
+
<button
|
|
239
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
240
|
+
className={`flex items-center justify-between w-full text-left group ${isExpanded ? 'mb-4' : ''}`}
|
|
241
|
+
aria-expanded={isExpanded}
|
|
242
|
+
aria-controls="price-content"
|
|
243
|
+
>
|
|
244
|
+
<h3 className="text-base font-semibold text-primary">
|
|
245
|
+
Price
|
|
246
|
+
</h3>
|
|
247
|
+
<svg
|
|
248
|
+
className={`w-5 h-5 transition-transform text-foreground/40 group-hover:text-foreground/60 ${isExpanded ? "rotate-180" : ""}`}
|
|
249
|
+
fill="none"
|
|
250
|
+
viewBox="0 0 24 24"
|
|
251
|
+
stroke="currentColor"
|
|
252
|
+
strokeWidth={2.5}
|
|
253
|
+
aria-hidden="true"
|
|
254
|
+
>
|
|
255
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
256
|
+
</svg>
|
|
257
|
+
</button>
|
|
258
|
+
|
|
259
|
+
{isExpanded && (
|
|
260
|
+
<div className="space-y-6" id="price-content">
|
|
261
|
+
{/* Price Display */}
|
|
262
|
+
<div className="flex items-center justify-between text-sm font-display">
|
|
263
|
+
<span>{formatPrice(localRange[0])}</span>
|
|
264
|
+
<span className="text-foreground/70">{formatPrice(localRange[1])}</span>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Histogram placeholder - simplified version - decorative only */}
|
|
268
|
+
<div className="relative h-24 flex items-end justify-between gap-0.5 px-1" aria-hidden="true" role="presentation">
|
|
269
|
+
{/* Simple bar chart representation */}
|
|
270
|
+
{[60, 40, 80, 50, 60, 30, 20, 15, 10, 5, 30, 20, 15, 10, 5, 3, 3, 60, 40].map((height, i) => {
|
|
271
|
+
// Calculate if this bar is in the selected range
|
|
272
|
+
const totalBars = 19;
|
|
273
|
+
const barPrice = rangeMin + ((rangeMax - rangeMin) / totalBars) * i;
|
|
274
|
+
const isInRange = barPrice >= localRange[0] && barPrice <= localRange[1];
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div
|
|
278
|
+
key={i}
|
|
279
|
+
className={`flex-1 rounded-t-sm transition-opacity ${
|
|
280
|
+
isInRange ? "bg-foreground" : "bg-foreground/20"
|
|
281
|
+
}`}
|
|
282
|
+
style={{ height: `${height}%` }}
|
|
283
|
+
/>
|
|
284
|
+
);
|
|
285
|
+
})}
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{/* Range Slider - styled like TileCount */}
|
|
289
|
+
<div style={{ touchAction: 'none' }} className="my-6">
|
|
290
|
+
<Slider.Root
|
|
291
|
+
className="relative flex w-full touch-none select-none items-center"
|
|
292
|
+
value={localRange}
|
|
293
|
+
onValueChange={(value) => {
|
|
294
|
+
setLocalRange(value as [number, number]);
|
|
295
|
+
}}
|
|
296
|
+
onValueCommit={(value) => {
|
|
297
|
+
const newRange = value as [number, number];
|
|
298
|
+
setLocalRange(newRange);
|
|
299
|
+
refine(newRange);
|
|
300
|
+
}}
|
|
301
|
+
min={rangeMin}
|
|
302
|
+
max={rangeMax}
|
|
303
|
+
step={1}
|
|
304
|
+
minStepsBetweenThumbs={1}
|
|
305
|
+
>
|
|
306
|
+
<Slider.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-foreground/20">
|
|
307
|
+
<Slider.Range className="absolute h-full bg-foreground" />
|
|
308
|
+
</Slider.Track>
|
|
309
|
+
<Slider.Thumb
|
|
310
|
+
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 border-2 border-primary"
|
|
311
|
+
style={{
|
|
312
|
+
touchAction: 'none',
|
|
313
|
+
}}
|
|
314
|
+
aria-label="Minimum price"
|
|
315
|
+
aria-valuemin={rangeMin}
|
|
316
|
+
aria-valuemax={rangeMax}
|
|
317
|
+
aria-valuenow={localRange[0]}
|
|
318
|
+
aria-valuetext={formatPrice(localRange[0])}
|
|
319
|
+
/>
|
|
320
|
+
<Slider.Thumb
|
|
321
|
+
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 border-2 border-primary"
|
|
322
|
+
style={{
|
|
323
|
+
touchAction: 'none',
|
|
324
|
+
}}
|
|
325
|
+
aria-label="Maximum price"
|
|
326
|
+
aria-valuemin={rangeMin}
|
|
327
|
+
aria-valuemax={rangeMax}
|
|
328
|
+
aria-valuenow={localRange[1]}
|
|
329
|
+
aria-valuetext={formatPrice(localRange[1])}
|
|
330
|
+
/>
|
|
331
|
+
</Slider.Root>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{/* Price Inputs */}
|
|
335
|
+
<div className="grid grid-cols-2 gap-3">
|
|
336
|
+
<div>
|
|
337
|
+
<label htmlFor="price-min" className="text-xs font-label text-foreground/60 mb-1.5 block">Min</label>
|
|
338
|
+
<input
|
|
339
|
+
id="price-min"
|
|
340
|
+
type="text"
|
|
341
|
+
value={formatPrice(localRange[0])}
|
|
342
|
+
onChange={(e) => {
|
|
343
|
+
const val = parseFloat(e.target.value.replace(/[^0-9.]/g, "")) * 100;
|
|
344
|
+
if (!isNaN(val) && val <= localRange[1]) {
|
|
345
|
+
setLocalRange([val, localRange[1]]);
|
|
346
|
+
}
|
|
347
|
+
}}
|
|
348
|
+
onBlur={() => refine([localRange[0], localRange[1]])}
|
|
349
|
+
className="w-full px-3 py-2 border-2 border-border/30 bg-background rounded-sm text-sm font-medium text-foreground transition-all hover:border-border/60 focus:outline-none focus:border-primary"
|
|
350
|
+
aria-label="Minimum price"
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
<div>
|
|
354
|
+
<label htmlFor="price-max" className="text-xs font-label text-foreground/60 mb-1.5 block">Max</label>
|
|
355
|
+
<input
|
|
356
|
+
id="price-max"
|
|
357
|
+
type="text"
|
|
358
|
+
value={formatPrice(localRange[1])}
|
|
359
|
+
onChange={(e) => {
|
|
360
|
+
const val = parseFloat(e.target.value.replace(/[^0-9.]/g, "")) * 100;
|
|
361
|
+
if (!isNaN(val) && val >= localRange[0]) {
|
|
362
|
+
setLocalRange([localRange[0], val]);
|
|
363
|
+
}
|
|
364
|
+
}}
|
|
365
|
+
onBlur={() => refine([localRange[0], localRange[1]])}
|
|
366
|
+
className="w-full px-3 py-2 border-2 border-border/30 bg-background rounded-sm text-sm font-medium text-foreground transition-all hover:border-border/60 focus:outline-none focus:border-primary"
|
|
367
|
+
aria-label="Maximum price"
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Hits, useStats, useInstantSearch } from "react-instantsearch";
|
|
4
|
+
import { ProductHitComponent } from "./ProductHit";
|
|
5
|
+
import { useState, useEffect, useCallback } from "react";
|
|
6
|
+
import type { ProductHit } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface ProductGridProps {
|
|
9
|
+
/**
|
|
10
|
+
* Additional CSS classes for the grid container
|
|
11
|
+
*/
|
|
12
|
+
className?: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Grid column classes (Tailwind responsive grid)
|
|
16
|
+
*/
|
|
17
|
+
gridClassName?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ProductCard variant to use for each product
|
|
21
|
+
*/
|
|
22
|
+
variant?: "default" | "overlay" | "minimal";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Number of skeleton items to show while loading
|
|
26
|
+
*/
|
|
27
|
+
skeletonCount?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ProductGridSkeleton - Skeleton placeholder for product grid items
|
|
32
|
+
*/
|
|
33
|
+
function ProductGridSkeletonItem() {
|
|
34
|
+
return (
|
|
35
|
+
<div className="aspect-square bg-muted animate-pulse" />
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ProductGrid - Displays search results in a responsive grid
|
|
41
|
+
*
|
|
42
|
+
* Must be used within a SearchProvider and Shop context.
|
|
43
|
+
* Gets the current artwork from useShop() context automatically.
|
|
44
|
+
* Shows skeleton placeholders while the initial search is loading.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <Shop>
|
|
49
|
+
* <ArtSelector artworks={artworks} />
|
|
50
|
+
* <SearchProvider>
|
|
51
|
+
* <SearchBox />
|
|
52
|
+
* <ProductGrid gridClassName="grid-cols-2 md:grid-cols-4" />
|
|
53
|
+
* </SearchProvider>
|
|
54
|
+
* </Shop>
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function ProductGrid({
|
|
58
|
+
className = "",
|
|
59
|
+
gridClassName = "grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5",
|
|
60
|
+
variant = "overlay",
|
|
61
|
+
skeletonCount = 10,
|
|
62
|
+
}: ProductGridProps) {
|
|
63
|
+
const { nbHits } = useStats();
|
|
64
|
+
const { status, results } = useInstantSearch();
|
|
65
|
+
const [announcement, setAnnouncement] = useState("");
|
|
66
|
+
|
|
67
|
+
// Derive skeleton state directly from results - no need for separate state
|
|
68
|
+
// Show skeleton only when we have no results yet (initial load)
|
|
69
|
+
const hitsCount = results?.hits?.length ?? 0;
|
|
70
|
+
const showSkeleton = hitsCount === 0 && status !== 'error';
|
|
71
|
+
|
|
72
|
+
// Announce search results changes to screen readers
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
setAnnouncement(`${nbHits} ${nbHits === 1 ? 'product' : 'products'} found`);
|
|
76
|
+
}, 1000);
|
|
77
|
+
return () => clearTimeout(timer);
|
|
78
|
+
}, [nbHits]);
|
|
79
|
+
|
|
80
|
+
// Memoize the hit component to prevent recreating on every render
|
|
81
|
+
// This prevents all product images from reloading when anything changes
|
|
82
|
+
const HitComponent = useCallback(({ hit }: { hit: any }) => (
|
|
83
|
+
<ProductHitComponent
|
|
84
|
+
hit={hit as unknown as ProductHit}
|
|
85
|
+
variant={variant}
|
|
86
|
+
/>
|
|
87
|
+
), [variant]);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className={className}>
|
|
91
|
+
{/* Live region for screen reader announcements */}
|
|
92
|
+
<div className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
|
93
|
+
{announcement}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Skeleton Grid - shown during initial load */}
|
|
97
|
+
{showSkeleton && (
|
|
98
|
+
<div className={`grid ${gridClassName}`}>
|
|
99
|
+
{Array.from({ length: skeletonCount }).map((_, i) => (
|
|
100
|
+
<ProductGridSkeletonItem key={i} />
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{/* Product Grid - always rendered but hidden during skeleton */}
|
|
106
|
+
<div className={showSkeleton ? "hidden" : ""}>
|
|
107
|
+
<Hits
|
|
108
|
+
hitComponent={HitComponent}
|
|
109
|
+
classNames={{
|
|
110
|
+
list: `grid ${gridClassName} [&>li]:!p-0 [&>li]:!m-0`,
|
|
111
|
+
item: "",
|
|
112
|
+
root: "",
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useUiRouter } from "../../lib/router";
|
|
4
|
+
import { Product } from "../../patterns/Product";
|
|
5
|
+
import { ProductCard } from "../ProductCard";
|
|
6
|
+
import { useShop } from "../../patterns/ShopProvider";
|
|
7
|
+
import type { ProductHit } from "./types";
|
|
8
|
+
|
|
9
|
+
interface ProductHitComponentProps {
|
|
10
|
+
hit: ProductHit;
|
|
11
|
+
variant?: "default" | "overlay" | "minimal";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ProductHitComponent({
|
|
15
|
+
hit: product,
|
|
16
|
+
variant = "overlay",
|
|
17
|
+
}: ProductHitComponentProps) {
|
|
18
|
+
const router = useUiRouter();
|
|
19
|
+
const { selectedArtwork: currentArtwork, addProduct } = useShop();
|
|
20
|
+
const productId = product.id || product.objectID;
|
|
21
|
+
|
|
22
|
+
const getClassName = () => {
|
|
23
|
+
if (variant === "overlay") {
|
|
24
|
+
return "rounded-none";
|
|
25
|
+
}
|
|
26
|
+
if (variant === "minimal") {
|
|
27
|
+
return ""; // Allow rounded corners for minimal variant
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Product
|
|
34
|
+
productId={productId}
|
|
35
|
+
productData={product}
|
|
36
|
+
>
|
|
37
|
+
<ProductCard
|
|
38
|
+
variant={variant}
|
|
39
|
+
showCategory
|
|
40
|
+
showPrice={variant !== "overlay"}
|
|
41
|
+
className={getClassName()}
|
|
42
|
+
onClick={() => {
|
|
43
|
+
// Cache product data before navigation so PDP can use it instantly
|
|
44
|
+
addProduct(product);
|
|
45
|
+
|
|
46
|
+
const artworkParam = currentArtwork?.src
|
|
47
|
+
? `?artwork=${encodeURIComponent(currentArtwork.src)}`
|
|
48
|
+
: "";
|
|
49
|
+
router.push(
|
|
50
|
+
`/products/${productId}${artworkParam}`
|
|
51
|
+
);
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</Product>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSearchBox } from "react-instantsearch";
|
|
4
|
+
import { Search, X } from "lucide-react";
|
|
5
|
+
import { useUiRouter, useUiSearchParams } from "../../lib/router";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { Input } from "../../primitives/input";
|
|
8
|
+
|
|
9
|
+
export function SearchBox() {
|
|
10
|
+
const { query, refine, clear } = useSearchBox();
|
|
11
|
+
const searchParams = useUiSearchParams();
|
|
12
|
+
const router = useUiRouter();
|
|
13
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
14
|
+
const [showShimmer, setShowShimmer] = useState(false);
|
|
15
|
+
|
|
16
|
+
const inputId = 'search-products-input';
|
|
17
|
+
const descriptionId = 'search-description';
|
|
18
|
+
|
|
19
|
+
// Listen for spotlight event (triggered by search icon click)
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const handleSpotlight = () => {
|
|
22
|
+
inputRef.current?.focus();
|
|
23
|
+
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
24
|
+
setShowShimmer(true);
|
|
25
|
+
setTimeout(() => setShowShimmer(false), 600);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
window.addEventListener('search-spotlight', handleSpotlight);
|
|
29
|
+
return () => window.removeEventListener('search-spotlight', handleSpotlight);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
// Focus search box when navigating from product page
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const shouldFocus = searchParams.get('focus') === 'search';
|
|
35
|
+
if (shouldFocus && inputRef.current) {
|
|
36
|
+
const timer = setTimeout(() => {
|
|
37
|
+
inputRef.current?.focus();
|
|
38
|
+
setShowShimmer(true);
|
|
39
|
+
setTimeout(() => setShowShimmer(false), 600);
|
|
40
|
+
|
|
41
|
+
// Clear the focus parameter from the URL
|
|
42
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
43
|
+
params.delete('focus');
|
|
44
|
+
const newUrl = params.toString() ? `?${params.toString()}` : '/';
|
|
45
|
+
router.replace(newUrl);
|
|
46
|
+
}, 100);
|
|
47
|
+
return () => clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}, [searchParams, router]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<form role="search" onSubmit={(e) => e.preventDefault()}>
|
|
53
|
+
<div className="relative">
|
|
54
|
+
<Input
|
|
55
|
+
ref={inputRef}
|
|
56
|
+
id={inputId}
|
|
57
|
+
type="search"
|
|
58
|
+
value={query}
|
|
59
|
+
onChange={(e) => refine(e.target.value)}
|
|
60
|
+
placeholder="Search products..."
|
|
61
|
+
startContent={
|
|
62
|
+
<Search className="w-5 h-5" strokeWidth={1.5} aria-hidden="true" />
|
|
63
|
+
}
|
|
64
|
+
endContent={
|
|
65
|
+
query ? (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
onClick={clear}
|
|
69
|
+
className="hover:text-foreground transition-colors"
|
|
70
|
+
aria-label="Clear search"
|
|
71
|
+
>
|
|
72
|
+
<X className="w-4 h-4" strokeWidth={2} aria-hidden="true" />
|
|
73
|
+
</button>
|
|
74
|
+
) : undefined
|
|
75
|
+
}
|
|
76
|
+
aria-label="Search products"
|
|
77
|
+
aria-describedby={descriptionId}
|
|
78
|
+
autoComplete="off"
|
|
79
|
+
aria-autocomplete="list"
|
|
80
|
+
className="[&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
|
81
|
+
/>
|
|
82
|
+
{/* Shimmer effect - overflow-hidden only on shimmer container to not clip input shadow */}
|
|
83
|
+
{showShimmer && (
|
|
84
|
+
<div
|
|
85
|
+
className="absolute inset-0 pointer-events-none rounded-input z-20 overflow-hidden"
|
|
86
|
+
>
|
|
87
|
+
<div
|
|
88
|
+
className="absolute inset-0"
|
|
89
|
+
style={{
|
|
90
|
+
background: 'linear-gradient(90deg, transparent 0%, var(--color-primary, #3b82f6) 50%, transparent 100%)',
|
|
91
|
+
opacity: 0.3,
|
|
92
|
+
animation: 'searchShimmer 0.6s ease-out forwards',
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
<style dangerouslySetInnerHTML={{ __html: `
|
|
98
|
+
@keyframes searchShimmer {
|
|
99
|
+
0% { transform: translateX(-100%); }
|
|
100
|
+
100% { transform: translateX(100%); }
|
|
101
|
+
}
|
|
102
|
+
`}} />
|
|
103
|
+
<span id={descriptionId} className="sr-only">
|
|
104
|
+
Search results will update as you type
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|