
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
E-commerce product listing page (PLP) featuring breadcrumb navigation, category header with sorting and mobile filter drawer, a 2-column layout with a multi-facet sidebar (categories, price range slider, color swatches, brands, ratings), and a responsive 3-column product grid with image hover zoom, badges, wishlist toggle, color swatches, quick add to cart, and pagination.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/product-grid-page.json$npx shadcn@latest add https://uipkge.dev/r/react/product-grid-page.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/product-grid-page.json$bunx shadcn@latest add https://uipkge.dev/r/react/product-grid-page.jsonnpx shadcn@latest add @uipkge-react/product-grid-pageInstalls to:components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
id | 'prod-1', | — | required |
name | 'XT-6 GORE-TEX Trail Running Shoe', | — | required |
brand | 'Salomon', | — | required |
category | 'Running', | — | required |
categoryId | 'running', | — | required |
price | 220, | — | required |
originalPrice | 245, | — | required |
rating | 4.9, | — | required |
reviewCount | 128, | — | required |
badge | { label: 'Best Seller', variant: 'default' }, | — | required |
image | 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=700&auto=format&fit=crop&q=80', | — | required |
colors | [ | — | required |
createdAt | 1708000000000, | — | required |
featuredRank | 1, | — | required |
onToggleCategory | toggleCategory, | — | required |
onPriceRangeChange | (val: number[]) => setPriceRange(val), | — | required |
onToggleColor | toggleColor, | — | required |
onToggleBrand | toggleBrand, | — | required |
onSelectRating | setRating, | — | required |
Price | ${priceRange[0]} - ${priceRange[1]} | — | required |
Type aliases exported from this item's source. Use these to shape the data you pass in.
ProductColorinterface ProductColor {
id: string
name: string
class: string
colorCode: string
}Productinterface Product {
id: string
name: string
brand: string
category: string
categoryId: string
price: number
originalPrice?: number
rating: number
reviewCount: number
badge?: {
label: string
variant: 'default' | 'secondary' | 'destructive' | 'outline'
}
image: string
colors: ProductColor[]
createdAt: number
featuredRank: number
}CategoryOptioninterface CategoryOption {
id: string
label: string
}BrandOptioninterface BrandOption {
id: string
label: string
}ColorOptioninterface ColorOption {
id: string
label: string
class: string
}'use client'
import * as React from 'react'
import {
Check,
ChevronLeft,
ChevronRight,
Heart,
RotateCcw,
ShoppingBag,
SlidersHorizontal,
Star,
X,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { CATEGORIES, COLORS, ProductFilterSidebar } from './ProductFilterSidebar'
interface ProductColor {
id: string
name: string
class: string
colorCode: string
}
interface Product {
id: string
name: string
brand: string
category: string
categoryId: string
price: number
originalPrice?: number
rating: number
reviewCount: number
badge?: {
label: string
variant: 'default' | 'secondary' | 'destructive' | 'outline'
}
image: string
colors: ProductColor[]
createdAt: number
featuredRank: number
}
export interface ProductGridPageProps extends React.HTMLAttributes<HTMLDivElement> {}
const PRODUCTS: Product[] = [
{
id: 'prod-1',
name: 'XT-6 GORE-TEX Trail Running Shoe',
brand: 'Salomon',
category: 'Running',
categoryId: 'running',
price: 220,
originalPrice: 245,
rating: 4.9,
reviewCount: 128,
badge: { label: 'Best Seller', variant: 'default' },
image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c1', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
{ id: 'c2', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
{ id: 'c3', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
],
createdAt: 1708000000000,
featuredRank: 1,
},
{
id: 'prod-2',
name: 'Air Max Pulse Premium',
brand: 'Nike',
category: 'Sneakers',
categoryId: 'sneakers',
price: 150,
originalPrice: 180,
rating: 4.8,
reviewCount: 94,
badge: { label: '-20% OFF', variant: 'destructive' },
image: 'https://images.unsplash.com/photo-1552346154-21d32810aba3?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c4', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
{ id: 'c5', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
{ id: 'c6', name: 'Amber', class: 'bg-warning', colorCode: 'amber' },
],
createdAt: 1708500000000,
featuredRank: 2,
},
{
id: 'prod-3',
name: '990v6 Made in USA Core Sneaker',
brand: 'New Balance',
category: 'Sneakers',
categoryId: 'sneakers',
price: 200,
rating: 4.9,
reviewCount: 215,
badge: { label: 'New', variant: 'secondary' },
image: 'https://images.unsplash.com/photo-1539185441755-769473a23570?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c7', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
{ id: 'c8', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
{ id: 'c9', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
],
createdAt: 1709000000000,
featuredRank: 3,
},
{
id: 'prod-4',
name: 'Ultraboost Light Performance Runner',
brand: 'Adidas',
category: 'Running',
categoryId: 'running',
price: 190,
originalPrice: 210,
rating: 4.7,
reviewCount: 86,
image: 'https://images.unsplash.com/photo-1587563871167-1ee9c731aefb?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c10', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
{ id: 'c11', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
],
createdAt: 1707000000000,
featuredRank: 4,
},
{
id: 'prod-5',
name: 'Tor Ultra Hi Waterproof Boot',
brand: 'Salomon',
category: 'Boots',
categoryId: 'boots',
price: 260,
rating: 4.8,
reviewCount: 62,
badge: { label: 'Trending', variant: 'outline' },
image: 'https://images.unsplash.com/photo-1608231387042-66d1773070a5?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c12', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
{ id: 'c13', name: 'Amber', class: 'bg-warning', colorCode: 'amber' },
],
createdAt: 1708800000000,
featuredRank: 5,
},
{
id: 'prod-6',
name: 'Penny Lug Leather Loafer',
brand: 'Adidas',
category: 'Loafers',
categoryId: 'loafers',
price: 160,
originalPrice: 190,
rating: 4.5,
reviewCount: 41,
badge: { label: 'Sale', variant: 'destructive' },
image: 'https://images.unsplash.com/photo-1533867617858-e7b97e060509?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c14', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
{ id: 'c15', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
],
createdAt: 1706000000000,
featuredRank: 6,
},
{
id: 'prod-7',
name: 'Fresh Foam X Trail 574 v2',
brand: 'New Balance',
category: 'Running',
categoryId: 'running',
price: 135,
originalPrice: 155,
rating: 4.6,
reviewCount: 78,
image: 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c16', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
{ id: 'c17', name: 'Amber', class: 'bg-warning', colorCode: 'amber' },
{ id: 'c18', name: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border border-zinc-300', colorCode: 'white' },
],
createdAt: 1707500000000,
featuredRank: 7,
},
{
id: 'prod-8',
name: 'Tech Fleece Runner Low Boot',
brand: 'Nike',
category: 'Boots',
categoryId: 'boots',
price: 185,
rating: 4.7,
reviewCount: 53,
badge: { label: 'New', variant: 'secondary' },
image: 'https://images.unsplash.com/photo-1514989940723-e8e51635b782?w=700&auto=format&fit=crop&q=80',
colors: [
{ id: 'c19', name: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900', colorCode: 'black' },
{ id: 'c20', name: 'Navy', class: 'bg-slate-900', colorCode: 'navy' },
{ id: 'c21', name: 'Sage', class: 'bg-chart-2', colorCode: 'sage' },
],
createdAt: 1708900000000,
featuredRank: 8,
},
]
export function ProductGridPage({ className, ...props }: ProductGridPageProps) {
const [selectedCategories, setSelectedCategories] = React.useState<string[]>([])
const [priceRange, setPriceRange] = React.useState<number[]>([50, 300])
const [selectedColors, setSelectedColors] = React.useState<string[]>([])
const [selectedBrands, setSelectedBrands] = React.useState<string[]>([])
const [minRating, setMinRating] = React.useState<number>(0)
const [sortBy, setSortBy] = React.useState<string>('featured')
const [wishlistIds, setWishlistIds] = React.useState<Set<string>>(new Set(['prod-1', 'prod-5']))
const [addedToCartIds, setAddedToCartIds] = React.useState<Set<string>>(new Set())
const [activeProductColors, setActiveProductColors] = React.useState<Record<string, string>>({})
const [page, setPage] = React.useState<number>(1)
const [isMobileFilterOpen, setIsMobileFilterOpen] = React.useState<boolean>(false)
const totalPages = 3
const toggleCategory = (id: string, checked: boolean) => {
setSelectedCategories((prev) => (checked ? [...prev, id] : prev.filter((item) => item !== id)))
setPage(1)
}
const toggleBrand = (id: string, checked: boolean) => {
setSelectedBrands((prev) => (checked ? [...prev, id] : prev.filter((item) => item !== id)))
setPage(1)
}
const toggleColor = (id: string) => {
setSelectedColors((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]))
setPage(1)
}
const setRating = (val: number) => {
setMinRating((prev) => (prev === val ? 0 : val))
setPage(1)
}
const toggleWishlist = (id: string) => {
setWishlistIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const addToCart = (id: string) => {
setAddedToCartIds((prev) => {
const next = new Set(prev)
next.add(id)
return next
})
setTimeout(() => {
setAddedToCartIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, 1600)
}
const selectProductColor = (productId: string, colorCode: string) => {
setActiveProductColors((prev) => ({
...prev,
[productId]: colorCode,
}))
}
const resetFilters = () => {
setSelectedCategories([])
setPriceRange([50, 300])
setSelectedColors([])
setSelectedBrands([])
setMinRating(0)
setPage(1)
}
const hasActiveFilters =
selectedCategories.length > 0 ||
selectedBrands.length > 0 ||
selectedColors.length > 0 ||
minRating > 0 ||
priceRange[0] > 50 ||
priceRange[1] < 300
const activeFilterCount =
selectedCategories.length +
selectedBrands.length +
selectedColors.length +
(minRating > 0 ? 1 : 0) +
(priceRange[0] > 50 || priceRange[1] < 300 ? 1 : 0)
const getCategoryCount = (catId: string) => PRODUCTS.filter((p) => p.categoryId === catId).length
const getBrandCount = (brand: string) => PRODUCTS.filter((p) => p.brand === brand).length
const filteredProducts = React.useMemo(() => {
return PRODUCTS.filter((product) => {
if (selectedCategories.length > 0 && !selectedCategories.includes(product.categoryId)) {
return false
}
if (product.price < priceRange[0] || product.price > priceRange[1]) {
return false
}
if (selectedColors.length > 0) {
const hasMatchingColor = product.colors.some((c) => selectedColors.includes(c.colorCode))
if (!hasMatchingColor) return false
}
if (selectedBrands.length > 0 && !selectedBrands.includes(product.brand)) {
return false
}
if (minRating > 0 && product.rating < minRating) {
return false
}
return true
}).sort((a, b) => {
if (sortBy === 'price-asc') return a.price - b.price
if (sortBy === 'price-desc') return b.price - a.price
if (sortBy === 'rating-desc') return b.rating - a.rating
if (sortBy === 'newest') return b.createdAt - a.createdAt
return a.featuredRank - b.featuredRank
})
}, [selectedCategories, priceRange, selectedColors, selectedBrands, minRating, sortBy])
const filterSidebarProps = {
selectedCategories,
priceRange,
selectedColors,
selectedBrands,
minRating,
onToggleCategory: toggleCategory,
onPriceRangeChange: (val: number[]) => setPriceRange(val),
onToggleColor: toggleColor,
onToggleBrand: toggleBrand,
onSelectRating: setRating,
getCategoryCount,
getBrandCount,
}
return (
<div data-slot="product-grid-page" className={cn('w-full space-y-8', className)} {...props}>
{/* Breadcrumb */}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#" className="inline-flex min-h-6 items-center">
Home
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#" className="inline-flex min-h-6 items-center">
Shop
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Footwear</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{/* Header & Toolbar */}
<div className="border-border flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">All Footwear</h1>
<Badge variant="secondary" className="font-normal tabular-nums">
{filteredProducts.length} items
</Badge>
</div>
<p className="text-muted-foreground text-xs sm:text-sm">
Engineered for performance, trail endurance, and everyday urban utility.
</p>
</div>
<div className="flex items-center gap-2">
{/* Mobile Filter Drawer */}
<Sheet open={isMobileFilterOpen} onOpenChange={setIsMobileFilterOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 md:hidden">
<SlidersHorizontal className="size-4" />
<span>Filters</span>
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-xs">
{activeFilterCount}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-full max-w-xs overflow-y-auto p-6">
<SheetHeader className="text-left">
<div className="flex items-center justify-between">
<SheetTitle className="text-base font-semibold">Filters</SheetTitle>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
onClick={resetFilters}
>
Reset all
</Button>
)}
</div>
<SheetDescription className="text-xs">
Narrow down footwear by category, price, and specs.
</SheetDescription>
</SheetHeader>
<div className="mt-6">
<ProductFilterSidebar idPrefix="mobile-" {...filterSidebarProps} />
</div>
</SheetContent>
</Sheet>
{/* Sort Select */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground hidden text-xs sm:inline-block">Sort by:</span>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="h-9 w-44 text-xs sm:text-sm">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="featured">Featured</SelectItem>
<SelectItem value="price-asc">Price: Low to High</SelectItem>
<SelectItem value="price-desc">Price: High to Low</SelectItem>
<SelectItem value="rating-desc">Highest Rated</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 2-Column Main Layout */}
<div className="grid grid-cols-1 gap-8 md:grid-cols-[240px_1fr] lg:grid-cols-[260px_1fr]">
{/* Left Desktop Sidebar */}
<aside className="hidden space-y-6 md:block" aria-label="Product filters">
<div className="flex items-center justify-between">
<h2 className="text-foreground text-sm font-semibold tracking-tight">Filters</h2>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
onClick={resetFilters}
>
Reset all
</Button>
)}
</div>
<Separator />
<ProductFilterSidebar {...filterSidebarProps} />
</aside>
{/* Right Product Grid Area */}
<section className="min-w-0 space-y-6">
{/* Active Filter Chips */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
{selectedCategories.map((catId) => (
<Badge key={catId} variant="secondary" className="gap-1.5 py-1 text-xs">
<span>Category: {CATEGORIES.find((c) => c.id === catId)?.label}</span>
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:outline-none"
aria-label="Remove category filter"
onClick={() => toggleCategory(catId, false)}
>
<X className="size-3" />
</button>
</Badge>
))}
{selectedBrands.map((brand) => (
<Badge key={brand} variant="secondary" className="gap-1.5 py-1 text-xs">
<span>Brand: {brand}</span>
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:outline-none"
aria-label="Remove brand filter"
onClick={() => toggleBrand(brand, false)}
>
<X className="size-3" />
</button>
</Badge>
))}
{selectedColors.map((colorId) => (
<Badge key={colorId} variant="secondary" className="gap-1.5 py-1 text-xs">
<span>Color: {COLORS.find((c) => c.id === colorId)?.label}</span>
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:outline-none"
aria-label="Remove color filter"
onClick={() => toggleColor(colorId)}
>
<X className="size-3" />
</button>
</Badge>
))}
{minRating > 0 && (
<Badge variant="secondary" className="gap-1.5 py-1 text-xs">
<span>Rating: {minRating}+ Stars</span>
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:outline-none"
aria-label="Remove rating filter"
onClick={() => setMinRating(0)}
>
<X className="size-3" />
</button>
</Badge>
)}
{(priceRange[0] > 50 || priceRange[1] < 300) && (
<Badge variant="secondary" className="gap-1.5 py-1 text-xs">
<span>
Price: ${priceRange[0]} - ${priceRange[1]}
</span>
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:outline-none"
aria-label="Reset price filter"
onClick={() => setPriceRange([50, 300])}
>
<X className="size-3" />
</button>
</Badge>
)}
<Button
variant="link"
size="sm"
className="text-muted-foreground hover:text-foreground h-auto px-1 text-xs"
onClick={resetFilters}
>
Clear all
</Button>
</div>
)}
{/* Empty State */}
{filteredProducts.length === 0 ? (
<div className="border-border flex flex-col items-center justify-center rounded-xl border border-dashed py-16 text-center">
<div className="bg-muted text-muted-foreground flex size-12 items-center justify-center rounded-full">
<RotateCcw className="size-6" />
</div>
<h3 className="text-foreground mt-4 text-base font-semibold">No matching footwear found</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-xs sm:text-sm">
Try adjusting your price range, clearing selected colors, or resetting your filters to see more results.
</p>
<Button variant="outline" size="sm" className="mt-4 gap-2" onClick={resetFilters}>
<RotateCcw className="size-3.5" />
<span>Reset filters</span>
</Button>
</div>
) : (
/* 3-Column Product Grid */
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filteredProducts.map((product) => (
<div
key={product.id}
className="group border-border bg-card hover:border-border/80 relative flex flex-col overflow-hidden rounded-xl border p-3 shadow-xs transition-colors duration-200 hover:shadow-md"
>
{/* Image Aspect Square Container */}
<div className="bg-muted/40 relative aspect-square w-full overflow-hidden rounded-lg">
<img
src={product.image}
alt={product.name}
className="h-full w-full object-cover object-center transition-transform duration-500 ease-out group-hover:scale-105"
loading="lazy"
/>
{/* Top Left Badge */}
{product.badge && (
<div className="absolute top-2.5 left-2.5 z-10">
<Badge variant={product.badge.variant} className="text-xs shadow-xs">
{product.badge.label}
</Badge>
</div>
)}
{/* Top Right Wishlist Heart */}
<button
type="button"
aria-label="Toggle wishlist"
className={cn(
'bg-background/80 hover:bg-background text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute top-2.5 right-2.5 z-10 flex size-8 items-center justify-center rounded-full shadow-xs backdrop-blur-xs transition-colors focus-visible:ring-2 focus-visible:outline-none',
wishlistIds.has(product.id) && 'text-destructive hover:text-destructive',
)}
onClick={(e) => {
e.stopPropagation()
toggleWishlist(product.id)
}}
>
<Heart
className={cn(
'size-4 transition-transform active:scale-90',
wishlistIds.has(product.id) && 'fill-current',
)}
/>
</button>
{/* Quick Add Button (Visible on hover & focus) */}
<div className="absolute inset-x-2.5 bottom-2.5 z-10 opacity-100 sm:translate-y-2 sm:opacity-0 sm:transition-colors sm:duration-200 sm:group-hover:translate-y-0 sm:group-hover:opacity-100">
<Button
size="sm"
className="w-full gap-1.5 text-xs font-medium shadow-sm"
variant="default"
onClick={(e) => {
e.stopPropagation()
addToCart(product.id)
}}
>
{addedToCartIds.has(product.id) ? (
<>
<Check className="size-3.5" />
<span>Added to Cart</span>
</>
) : (
<>
<ShoppingBag className="size-3.5" />
<span>Quick Add</span>
</>
)}
</Button>
</div>
</div>
{/* Card Body */}
<div className="mt-3 flex flex-1 flex-col justify-between space-y-2">
<div>
{/* Color swatches preview */}
<div className="flex items-center gap-1.5">
{product.colors.map((color) => (
<button
key={color.id}
type="button"
title={color.name}
className={cn(
'size-6 rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none',
color.class,
activeProductColors[product.id] === color.colorCode
? 'ring-1.5 ring-primary ring-offset-background ring-offset-1'
: '',
)}
onClick={(e) => {
e.stopPropagation()
selectProductColor(product.id, color.colorCode)
}}
>
<span className="sr-only">{color.name}</span>
</button>
))}
<span className="text-muted-foreground ml-1 text-xs">
{product.colors.length} {product.colors.length === 1 ? 'color' : 'colors'}
</span>
</div>
{/* Brand & Category */}
<div className="text-muted-foreground mt-2 flex items-center justify-between text-xs">
<span>
{product.brand} · {product.category}
</span>
<div className="flex items-center gap-1">
<Star className="fill-warning text-warning size-3" />
<span className="text-foreground font-medium">{product.rating}</span>
<span className="text-muted-foreground">({product.reviewCount})</span>
</div>
</div>
{/* Title */}
<h3
className="text-foreground group-hover:text-primary mt-1 line-clamp-1 cursor-pointer text-sm font-medium transition-colors"
title={product.name}
>
{product.name}
</h3>
</div>
{/* Price Row */}
<div className="flex min-w-0 flex-wrap items-baseline gap-2 pt-1">
<span className="text-foreground text-base font-semibold">${product.price.toFixed(2)}</span>
{product.originalPrice && (
<span className="text-muted-foreground text-xs line-through">
${product.originalPrice.toFixed(2)}
</span>
)}
{product.originalPrice && (
<span className="text-success text-xs font-medium">
Save ${(product.originalPrice - product.price).toFixed(2)}
</span>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Pagination Footer */}
{filteredProducts.length > 0 && (
<nav className="border-border flex items-center justify-between border-t pt-6" aria-label="Pagination">
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
<span>Previous</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
<Button
key={n}
variant={n === page ? 'outline' : 'ghost'}
size="sm"
className="size-8 p-0 text-xs tabular-nums"
aria-current={n === page ? 'page' : undefined}
onClick={() => setPage(n)}
>
{n}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
className="gap-1 text-xs"
disabled={page === totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
<span>Next</span>
<ChevronRight className="size-4" />
</Button>
</nav>
)}
</section>
</div>
</div>
)
}
'use client'
import * as React from 'react'
import { Check, Star } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
export interface CategoryOption {
id: string
label: string
}
export interface BrandOption {
id: string
label: string
}
export interface ColorOption {
id: string
label: string
class: string
}
export const CATEGORIES: CategoryOption[] = [
{ id: 'sneakers', label: 'Sneakers' },
{ id: 'running', label: 'Running' },
{ id: 'boots', label: 'Boots' },
{ id: 'loafers', label: 'Loafers' },
]
export const BRANDS: BrandOption[] = [
{ id: 'Nike', label: 'Nike' },
{ id: 'Adidas', label: 'Adidas' },
{ id: 'New Balance', label: 'New Balance' },
{ id: 'Salomon', label: 'Salomon' },
]
export const COLORS: ColorOption[] = [
{ id: 'black', label: 'Black', class: 'bg-zinc-950 dark:bg-zinc-900 border-zinc-800' },
{ id: 'white', label: 'White', class: 'bg-zinc-100 dark:bg-zinc-200 border-zinc-300' },
{ id: 'sage', label: 'Sage', class: 'bg-chart-2 border-chart-2' },
{ id: 'navy', label: 'Navy', class: 'bg-slate-900 border-slate-800' },
{ id: 'amber', label: 'Amber', class: 'bg-warning border-warning' },
]
export const RATING_OPTIONS = [
{ value: 4.8, label: '4.8 stars & above' },
{ value: 4.5, label: '4.5 stars & above' },
{ value: 4.0, label: '4.0 stars & above' },
]
export interface ProductFilterSidebarProps {
idPrefix?: string
selectedCategories: string[]
priceRange: number[]
selectedColors: string[]
selectedBrands: string[]
minRating: number
onToggleCategory: (id: string, checked: boolean) => void
onPriceRangeChange: (value: number[]) => void
onToggleColor: (id: string) => void
onToggleBrand: (id: string, checked: boolean) => void
onSelectRating: (value: number) => void
getCategoryCount: (id: string) => number
getBrandCount: (brand: string) => number
}
export function ProductFilterSidebar({
idPrefix = '',
selectedCategories,
priceRange,
selectedColors,
selectedBrands,
minRating,
onToggleCategory,
onPriceRangeChange,
onToggleColor,
onToggleBrand,
onSelectRating,
getCategoryCount,
getBrandCount,
}: ProductFilterSidebarProps) {
return (
<div className="space-y-6">
{/* Categories */}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">Category</h3>
<div className="space-y-2.5">
{CATEGORIES.map((cat) => (
<div key={cat.id} className="flex items-center justify-between">
<label
htmlFor={`${idPrefix}cat-${cat.id}`}
className="text-foreground flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
id={`${idPrefix}cat-${cat.id}`}
checked={selectedCategories.includes(cat.id)}
onCheckedChange={(checked) => onToggleCategory(cat.id, checked === true)}
/>
<span>{cat.label}</span>
</label>
<span className="text-muted-foreground text-xs tabular-nums">{getCategoryCount(cat.id)}</span>
</div>
))}
</div>
</div>
<Separator />
{/* Price Range */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">Price Range</h3>
<span className="text-foreground text-xs font-medium tabular-nums">
${priceRange[0]} – ${priceRange[1]}
</span>
</div>
<Slider
value={priceRange}
onValueChange={(val) => onPriceRangeChange(val)}
min={50}
max={300}
step={5}
range
className="py-2"
/>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>$50</span>
<span>$300</span>
</div>
</div>
<Separator />
{/* Colors */}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">Color</h3>
<div className="flex flex-wrap gap-2">
{COLORS.map((color) => (
<button
key={color.id}
type="button"
title={color.label}
className={cn(
'focus-visible:ring-ring relative flex size-7 items-center justify-center rounded-full border transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:outline-none',
color.class,
selectedColors.includes(color.id)
? 'ring-primary ring-offset-background ring-2 ring-offset-2'
: 'border-border',
)}
onClick={() => onToggleColor(color.id)}
>
{selectedColors.includes(color.id) && (
<Check className={cn('size-3.5', color.id === 'white' ? 'text-zinc-950' : 'text-white')} />
)}
<span className="sr-only">{color.label}</span>
</button>
))}
</div>
</div>
<Separator />
{/* Brands */}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">Brand</h3>
<div className="space-y-2.5">
{BRANDS.map((b) => (
<div key={b.id} className="flex items-center justify-between">
<label
htmlFor={`${idPrefix}brand-${b.id}`}
className="text-foreground flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
id={`${idPrefix}brand-${b.id}`}
checked={selectedBrands.includes(b.id)}
onCheckedChange={(checked) => onToggleBrand(b.id, checked === true)}
/>
<span>{b.label}</span>
</label>
<span className="text-muted-foreground text-xs tabular-nums">{getBrandCount(b.id)}</span>
</div>
))}
</div>
</div>
<Separator />
{/* Rating */}
<div className="space-y-3">
<h3 className="text-muted-foreground text-xs font-medium tracking-wider uppercase">Customer Rating</h3>
<div className="space-y-1.5">
{RATING_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
'hover:bg-muted/60 flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-xs transition-colors',
minRating === opt.value ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground',
)}
onClick={() => onSelectRating(opt.value)}
>
<div className="flex items-center gap-1.5">
<Star className="fill-warning text-warning size-3.5" />
<span>{opt.label}</span>
</div>
{minRating === opt.value && <Check className="text-primary size-3.5" />}
</button>
))}
</div>
</div>
</div>
)
}
Raw manifest:https://uipkge.dev/r/react/product-grid-page.json