@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,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carousel Types
|
|
3
|
+
*
|
|
4
|
+
* Shared interfaces for carousel components.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Represents a single image in a carousel.
|
|
9
|
+
*
|
|
10
|
+
* This type supports both static images and dynamic mockups.
|
|
11
|
+
* For mockups, the `src` may be updated in real-time via the RealtimeProvider.
|
|
12
|
+
*/
|
|
13
|
+
export interface CarouselImage {
|
|
14
|
+
/** The image source URL */
|
|
15
|
+
src: string;
|
|
16
|
+
|
|
17
|
+
/** Natural width of the image in pixels */
|
|
18
|
+
naturalWidth: number;
|
|
19
|
+
|
|
20
|
+
/** Natural height of the image in pixels */
|
|
21
|
+
naturalHeight: number;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Anchor point for image positioning (0-1 range, vertical position).
|
|
25
|
+
* Used to determine which part of the image to show when cropped.
|
|
26
|
+
*/
|
|
27
|
+
imageAnchor: number;
|
|
28
|
+
|
|
29
|
+
/** Accessible label for the image */
|
|
30
|
+
label: string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether this is a real-time mockup that can be updated.
|
|
34
|
+
* When true, the carousel will integrate with RealtimeProvider
|
|
35
|
+
* to receive URL updates.
|
|
36
|
+
*/
|
|
37
|
+
isRealMockup?: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether this is a placeholder waiting for content.
|
|
41
|
+
* Displays a loading skeleton when true.
|
|
42
|
+
*/
|
|
43
|
+
isPlaceholder?: boolean;
|
|
44
|
+
|
|
45
|
+
/** Placement identifier for mockup generation */
|
|
46
|
+
placement?: string;
|
|
47
|
+
|
|
48
|
+
/** Mockup identifier for real-time updates */
|
|
49
|
+
mockupId?: string;
|
|
50
|
+
|
|
51
|
+
/** Aspect ratio of the mockup (e.g., 1.78 for 16:9) */
|
|
52
|
+
aspectRatio?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Alias for CarouselImage - used in hero contexts for semantic clarity.
|
|
57
|
+
*/
|
|
58
|
+
export type HeroImage = CarouselImage;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MasonryGrid - Responsive masonry layout primitive
|
|
5
|
+
*
|
|
6
|
+
* A pure layout component for creating masonry-style grids.
|
|
7
|
+
* No business logic - just handles responsive column calculation
|
|
8
|
+
* and distributing items across columns.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* // Basic usage with fixed columns
|
|
13
|
+
* <MasonryGrid
|
|
14
|
+
* items={artworks}
|
|
15
|
+
* columns={3}
|
|
16
|
+
* gap={8}
|
|
17
|
+
* renderItem={(artwork, index) => (
|
|
18
|
+
* <ArtworkCard key={artwork.id} artwork={artwork} />
|
|
19
|
+
* )}
|
|
20
|
+
* />
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* // Responsive columns
|
|
26
|
+
* <MasonryGrid
|
|
27
|
+
* items={products}
|
|
28
|
+
* columns={{ sm: 2, md: 3, lg: 4, xl: 5 }}
|
|
29
|
+
* gap={16}
|
|
30
|
+
* renderItem={(product, index) => (
|
|
31
|
+
* <ProductCard key={product.id} product={product} />
|
|
32
|
+
* )}
|
|
33
|
+
* />
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* // With custom breakpoints
|
|
39
|
+
* <MasonryGrid
|
|
40
|
+
* items={images}
|
|
41
|
+
* columns={{ base: 2, sm: 3, md: 4, lg: 5, xl: 6, "2xl": 7 }}
|
|
42
|
+
* renderItem={(image, index) => (
|
|
43
|
+
* <img key={index} src={image.src} alt={image.alt} />
|
|
44
|
+
* )}
|
|
45
|
+
* />
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import React, { useState, useEffect, useMemo } from "react";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Responsive column configuration.
|
|
53
|
+
* Keys are Tailwind-style breakpoint names, values are column counts.
|
|
54
|
+
*/
|
|
55
|
+
export interface ResponsiveColumns {
|
|
56
|
+
/** Default/base columns (< 640px) */
|
|
57
|
+
base?: number;
|
|
58
|
+
/** sm: >= 640px */
|
|
59
|
+
sm?: number;
|
|
60
|
+
/** md: >= 768px */
|
|
61
|
+
md?: number;
|
|
62
|
+
/** lg: >= 1024px */
|
|
63
|
+
lg?: number;
|
|
64
|
+
/** xl: >= 1280px */
|
|
65
|
+
xl?: number;
|
|
66
|
+
/** 2xl: >= 1536px */
|
|
67
|
+
"2xl"?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MasonryGridProps<T> {
|
|
71
|
+
/** Array of items to display */
|
|
72
|
+
items: T[];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render function for each item.
|
|
76
|
+
* @param item The item to render
|
|
77
|
+
* @param index The index of the item in the original array
|
|
78
|
+
*/
|
|
79
|
+
renderItem: (item: T, index: number) => React.ReactNode;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Number of columns or responsive column configuration.
|
|
83
|
+
* @default { base: 2, sm: 3, md: 4, lg: 5, xl: 6 }
|
|
84
|
+
*/
|
|
85
|
+
columns?: number | ResponsiveColumns;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Gap between items in pixels.
|
|
89
|
+
* @default 8
|
|
90
|
+
*/
|
|
91
|
+
gap?: number;
|
|
92
|
+
|
|
93
|
+
/** Additional CSS classes for the container */
|
|
94
|
+
className?: string;
|
|
95
|
+
|
|
96
|
+
/** Empty state content */
|
|
97
|
+
emptyContent?: React.ReactNode;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Tailwind-style breakpoints in pixels
|
|
101
|
+
const BREAKPOINTS = {
|
|
102
|
+
sm: 640,
|
|
103
|
+
md: 768,
|
|
104
|
+
lg: 1024,
|
|
105
|
+
xl: 1280,
|
|
106
|
+
"2xl": 1536,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default responsive column configuration
|
|
111
|
+
*/
|
|
112
|
+
const DEFAULT_COLUMNS: ResponsiveColumns = {
|
|
113
|
+
base: 2,
|
|
114
|
+
sm: 3,
|
|
115
|
+
md: 4,
|
|
116
|
+
lg: 5,
|
|
117
|
+
xl: 6,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get the column count for a given viewport width
|
|
122
|
+
*/
|
|
123
|
+
function getColumnCount(
|
|
124
|
+
width: number,
|
|
125
|
+
columns: number | ResponsiveColumns
|
|
126
|
+
): number {
|
|
127
|
+
if (typeof columns === "number") {
|
|
128
|
+
return columns;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Find the appropriate column count based on viewport width
|
|
132
|
+
// Check from largest to smallest breakpoint
|
|
133
|
+
if (width >= BREAKPOINTS["2xl"] && columns["2xl"] !== undefined) {
|
|
134
|
+
return columns["2xl"];
|
|
135
|
+
}
|
|
136
|
+
if (width >= BREAKPOINTS.xl && columns.xl !== undefined) {
|
|
137
|
+
return columns.xl;
|
|
138
|
+
}
|
|
139
|
+
if (width >= BREAKPOINTS.lg && columns.lg !== undefined) {
|
|
140
|
+
return columns.lg;
|
|
141
|
+
}
|
|
142
|
+
if (width >= BREAKPOINTS.md && columns.md !== undefined) {
|
|
143
|
+
return columns.md;
|
|
144
|
+
}
|
|
145
|
+
if (width >= BREAKPOINTS.sm && columns.sm !== undefined) {
|
|
146
|
+
return columns.sm;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return columns.base ?? 2;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Distribute items into columns for masonry layout.
|
|
154
|
+
* Items are distributed round-robin style to maintain visual order.
|
|
155
|
+
*/
|
|
156
|
+
function distributeItems<T>(items: T[], columnCount: number): T[][] {
|
|
157
|
+
const columns: T[][] = Array.from({ length: columnCount }, () => []);
|
|
158
|
+
|
|
159
|
+
items.forEach((item, index) => {
|
|
160
|
+
const columnIndex = index % columnCount;
|
|
161
|
+
columns[columnIndex].push(item);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return columns;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function MasonryGrid<T>({
|
|
168
|
+
items,
|
|
169
|
+
renderItem,
|
|
170
|
+
columns = DEFAULT_COLUMNS,
|
|
171
|
+
gap = 8,
|
|
172
|
+
className = "",
|
|
173
|
+
emptyContent,
|
|
174
|
+
}: MasonryGridProps<T>) {
|
|
175
|
+
const [columnCount, setColumnCount] = useState(() =>
|
|
176
|
+
typeof columns === "number"
|
|
177
|
+
? columns
|
|
178
|
+
: getColumnCount(
|
|
179
|
+
typeof window !== "undefined" ? window.innerWidth : 1024,
|
|
180
|
+
columns
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// Update column count on resize
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
const updateColumnCount = () => {
|
|
187
|
+
setColumnCount(getColumnCount(window.innerWidth, columns));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Initial update
|
|
191
|
+
updateColumnCount();
|
|
192
|
+
|
|
193
|
+
window.addEventListener("resize", updateColumnCount);
|
|
194
|
+
return () => window.removeEventListener("resize", updateColumnCount);
|
|
195
|
+
}, [columns]);
|
|
196
|
+
|
|
197
|
+
// Distribute items into columns
|
|
198
|
+
const distributedColumns = useMemo(
|
|
199
|
+
() => distributeItems(items, columnCount),
|
|
200
|
+
[items, columnCount]
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Handle empty state
|
|
204
|
+
if (items.length === 0) {
|
|
205
|
+
if (emptyContent) {
|
|
206
|
+
return <>{emptyContent}</>;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div
|
|
213
|
+
className={`flex ${className}`}
|
|
214
|
+
style={{ gap: `${gap}px` }}
|
|
215
|
+
role="list"
|
|
216
|
+
>
|
|
217
|
+
{distributedColumns.map((columnItems, columnIndex) => (
|
|
218
|
+
<div
|
|
219
|
+
key={columnIndex}
|
|
220
|
+
className="flex-1 flex flex-col"
|
|
221
|
+
style={{ gap: `${gap}px` }}
|
|
222
|
+
>
|
|
223
|
+
{columnItems.map((item, itemIndex) => {
|
|
224
|
+
// Calculate original index for the render function
|
|
225
|
+
const originalIndex = itemIndex * columnCount + columnIndex;
|
|
226
|
+
return (
|
|
227
|
+
<div key={originalIndex} role="listitem">
|
|
228
|
+
{renderItem(item, originalIndex)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
})}
|
|
232
|
+
</div>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export default MasonryGrid;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCurrentRefinements } from "react-instantsearch";
|
|
4
|
+
import { X as LucideX } from "lucide-react";
|
|
5
|
+
import 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
|
+
|
|
11
|
+
export function CurrentRefinements() {
|
|
12
|
+
const { items, refine } = useCurrentRefinements();
|
|
13
|
+
|
|
14
|
+
const formatPriceValue = (priceInCents: number): string => {
|
|
15
|
+
const priceInDollars = priceInCents / 100;
|
|
16
|
+
if (priceInDollars >= 100) {
|
|
17
|
+
return `$${(priceInDollars / 1000).toFixed(1)}k`;
|
|
18
|
+
} else if (priceInDollars >= 10) {
|
|
19
|
+
return `$${priceInDollars.toFixed(0)}`;
|
|
20
|
+
} else {
|
|
21
|
+
return `$${priceInDollars.toFixed(2)}`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const formatRefinementLabel = (attribute: string, label: string) => {
|
|
26
|
+
if (attribute === "price") {
|
|
27
|
+
// Handle price range format like "100 <= price <= 5000"
|
|
28
|
+
const rangeMatch = label.match(/(\d+)\s*<=\s*price\s*<=\s*(\d+)/);
|
|
29
|
+
if (rangeMatch) {
|
|
30
|
+
const min = parseInt(rangeMatch[1]);
|
|
31
|
+
const max = parseInt(rangeMatch[2]);
|
|
32
|
+
return `${formatPriceValue(min)} - ${formatPriceValue(max)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Handle >= format like "price >= 3434" or "≥ 3434"
|
|
36
|
+
const minMatch = label.match(/[≥>=]+\s*(\d+)/);
|
|
37
|
+
if (minMatch) {
|
|
38
|
+
const min = parseInt(minMatch[1]);
|
|
39
|
+
return `Min ${formatPriceValue(min)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Handle <= format like "price <= 1000" or "≤ 1000"
|
|
43
|
+
const maxMatch = label.match(/[≤<=]+\s*(\d+)/);
|
|
44
|
+
if (maxMatch) {
|
|
45
|
+
const max = parseInt(maxMatch[1]);
|
|
46
|
+
return `Max ${formatPriceValue(max)}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return label;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const allRefinements = items.flatMap((item) =>
|
|
53
|
+
item.refinements.map((refinement) => ({
|
|
54
|
+
...refinement,
|
|
55
|
+
refine: () => refine(refinement),
|
|
56
|
+
attribute: item.attribute,
|
|
57
|
+
displayLabel: formatRefinementLabel(item.attribute, refinement.label),
|
|
58
|
+
}))
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (allRefinements.length === 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-wrap items-center gap-2 mb-4 -mt-1" role="group" aria-label="Active filters">
|
|
67
|
+
{allRefinements.map((refinement, index) => (
|
|
68
|
+
<button
|
|
69
|
+
key={`${refinement.attribute}-${refinement.value}-${index}`}
|
|
70
|
+
className="bg-primary text-on-primary hover:bg-primary/90 inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm transition-colors"
|
|
71
|
+
onClick={() => refinement.refine()}
|
|
72
|
+
aria-label={`Remove filter: ${refinement.displayLabel}`}
|
|
73
|
+
>
|
|
74
|
+
<span className="truncate">{refinement.displayLabel}</span>
|
|
75
|
+
<XIcon className="w-3 h-3" />
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { FiltersButton } from "./FiltersButton";
|
|
5
|
+
import { FiltersDrawer } from "./FiltersDrawer";
|
|
6
|
+
|
|
7
|
+
export interface FiltersProps {
|
|
8
|
+
/**
|
|
9
|
+
* Optional className for the trigger button
|
|
10
|
+
*/
|
|
11
|
+
buttonClassName?: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Optional button text
|
|
15
|
+
*/
|
|
16
|
+
buttonText?: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Filters - Self-contained filters with button and drawer
|
|
21
|
+
*
|
|
22
|
+
* Manages its own open/close state internally so you don't need to set up useState.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <Filters />
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <Filters buttonText="Show Filters" buttonClassName="custom-class" />
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function Filters({ buttonClassName, buttonText }: FiltersProps) {
|
|
35
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<FiltersButton
|
|
40
|
+
onClick={() => setIsOpen(true)}
|
|
41
|
+
className={buttonClassName}
|
|
42
|
+
>
|
|
43
|
+
{buttonText}
|
|
44
|
+
</FiltersButton>
|
|
45
|
+
|
|
46
|
+
<FiltersDrawer isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SlidersHorizontal as LucideSlidersHorizontal } from "lucide-react";
|
|
4
|
+
import type { ComponentType } from "react";
|
|
5
|
+
import { Button } from "../../primitives/Button";
|
|
6
|
+
|
|
7
|
+
// Cast to fix React 19 type compatibility with lucide-react
|
|
8
|
+
type IconProps = { className?: string; size?: number };
|
|
9
|
+
const SlidersHorizontalIcon = LucideSlidersHorizontal as ComponentType<IconProps>;
|
|
10
|
+
|
|
11
|
+
export interface FiltersButtonProps {
|
|
12
|
+
/**
|
|
13
|
+
* Click handler to open filters drawer
|
|
14
|
+
*/
|
|
15
|
+
onClick: () => void;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Additional CSS classes
|
|
19
|
+
*/
|
|
20
|
+
className?: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Button text
|
|
24
|
+
*/
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* FiltersButton - Button to open the filters drawer
|
|
30
|
+
*
|
|
31
|
+
* Uses Button field variant to match Input styling.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* const [isOpen, setIsOpen] = useState(false);
|
|
36
|
+
*
|
|
37
|
+
* <FiltersButton onClick={() => setIsOpen(true)} />
|
|
38
|
+
* <FiltersDrawer isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export function FiltersButton({
|
|
42
|
+
onClick,
|
|
43
|
+
className = "",
|
|
44
|
+
children = "Filters",
|
|
45
|
+
}: FiltersButtonProps) {
|
|
46
|
+
return (
|
|
47
|
+
<Button
|
|
48
|
+
variant="field"
|
|
49
|
+
onClick={onClick}
|
|
50
|
+
className={className}
|
|
51
|
+
aria-label="Open filters"
|
|
52
|
+
>
|
|
53
|
+
<SlidersHorizontalIcon className="w-4 h-4" />
|
|
54
|
+
<span>{children}</span>
|
|
55
|
+
</Button>
|
|
56
|
+
);
|
|
57
|
+
}
|