E-commerce Product Grid
Product listing grid with filters, sort, pagination, and quick-view cards.
Ecommerceember
Install
npx shadcn add @uianvil/block-ecommerce-product-gridCopy the command above to install this block.
Files
page.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { SearchIcon, PlusIcon, StarIcon, ShoppingCartIcon } from "lucide-react";
interface Product {
id: number;
name: string;
price: number;
rating: number;
category: string;
color: string;
badge?: string;
}
const products: Product[] = [
{ id: 1, name: "Forge Anvil Pro", price: 249.99, rating: 5, category: "tools", color: "bg-amber-500/80", badge: "Best Seller" },
{ id: 2, name: "Blacksmith Tongs", price: 89.99, rating: 4, category: "tools", color: "bg-orange-600/80" },
{ id: 3, name: "Carbon Steel Billet", price: 34.99, rating: 4, category: "materials", color: "bg-zinc-600/80", badge: "New" },
{ id: 4, name: "Quenching Oil — 1 Gal", price: 42.5, rating: 5, category: "materials", color: "bg-emerald-700/80" },
{ id: 5, name: "Leather Apron", price: 124.99, rating: 3, category: "safety", color: "bg-yellow-800/80" },
{ id: 6, name: "Heat-Resistant Gloves", price: 64.99, rating: 4, category: "safety", color: "bg-red-700/80", badge: "Sale" },
];
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<StarIcon
key={i}
className={cn(
"size-3.5",
i < rating
? "fill-amber-400 text-amber-400"
: "fill-muted text-muted"
)}
/>
))}
</div>
);
}
export default function EcommerceProductGrid() {
const [search, setSearch] = React.useState("");
const [category, setCategory] = React.useState("all");
const filtered = products.filter((p) => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = category === "all" || p.category === category;
return matchesSearch && matchesCategory;
});
return (
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Products</h1>
<p className="text-sm text-muted-foreground">
Browse our catalog of forging essentials.
</p>
</div>
<Button size="default">
<PlusIcon data-icon="inline-start" className="size-4" />
Add Product
</Button>
</div>
{/* Filter Bar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search products…"
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-full sm:w-44">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="tools">Tools</SelectItem>
<SelectItem value="materials">Materials</SelectItem>
<SelectItem value="safety">Safety</SelectItem>
</SelectContent>
</Select>
</div>
{/* Product Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((product) => (
<Card key={product.id} className="group relative overflow-hidden">
{/* Image Placeholder */}
<div
className={cn(
"relative flex h-48 items-center justify-center",
product.color
)}
>
<span className="text-3xl font-bold text-white/30 select-none">
{product.name.charAt(0)}
</span>
{product.badge && (
<Badge
variant={product.badge === "Sale" ? "destructive" : "default"}
className="absolute right-3 top-3"
>
{product.badge}
</Badge>
)}
</div>
<CardContent className="space-y-2 pt-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-medium leading-snug">{product.name}</h3>
<span className="shrink-0 text-base font-semibold tabular-nums">
${product.price.toFixed(2)}
</span>
</div>
<StarRating rating={product.rating} />
</CardContent>
<CardFooter>
<Button variant="outline" className="w-full">
<ShoppingCartIcon data-icon="inline-start" className="size-4" />
Add to Cart
</Button>
</CardFooter>
</Card>
))}
</div>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
<p className="text-sm text-muted-foreground">
No products match your filters.
</p>
</div>
)}
</div>
);
}