E-commerce Product Grid

Product listing grid with filters, sort, pagination, and quick-view cards.

Ecommerceember

Install

npx shadcn add @uianvil/block-ecommerce-product-grid

Copy 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>
  );
}

Command Palette

Search for a command to run...