@snowcone-app/ui 0.1.43 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. package/dist/styles.css +0 -1
@@ -0,0 +1,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
+ }