E-commerce 1

Product grid with category filter pills, sale and new badges, wishlist toggles, star ratings, compare-at pricing and per-card add buttons.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/ecommerce-01.json

Code

components/blocks/ecommerce-01.tsx
"use client";

import * as React from "react";
import Image from "next/image";
import { Heart, Plus } from "lucide-react";

import { Badge } from "@/registry/hirael/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import { Rating } from "@/registry/hirael/components/rating";

type Category = "audio" | "wearables" | "travel" | "everyday";

const FILTERS: { value: Category | "all"; label: string }[] = [
  { value: "all", label: "All" },
  { value: "audio", label: "Audio" },
  { value: "wearables", label: "Wearables" },
  { value: "travel", label: "Travel" },
  { value: "everyday", label: "Everyday" },
];

type Product = {
  id: string;
  name: string;
  category: Category;
  price: string;
  compareAt?: string;
  rating: number;
  reviews: string;
  badge?: string;
  image: string;
};

const PRODUCTS: readonly Product[] = [
  {
    id: "atlas-headphones",
    name: "Atlas Over-Ear Headphones",
    category: "audio",
    price: "$249",
    rating: 4.8,
    reviews: "1,204",
    badge: "Bestseller",
    image:
      "https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "meridian-watch",
    name: "Meridian Chrono Watch",
    category: "wearables",
    price: "$389",
    compareAt: "$460",
    rating: 4.9,
    reviews: "318",
    badge: "−15%",
    image:
      "https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "volt-runners",
    name: "Volt Runner Sneakers",
    category: "everyday",
    price: "$129",
    rating: 4.6,
    reviews: "942",
    badge: "New",
    image:
      "https://images.unsplash.com/photo-1542291026-7eec264c27ff?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "carryall-pack",
    name: "Carryall Day Pack",
    category: "travel",
    price: "$96",
    rating: 4.7,
    reviews: "566",
    image:
      "https://images.unsplash.com/photo-1553062407-98eeb64c6a62?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "horizon-sunglasses",
    name: "Horizon Sunglasses",
    category: "everyday",
    price: "$74",
    compareAt: "$92",
    rating: 4.5,
    reviews: "211",
    badge: "−20%",
    image:
      "https://images.unsplash.com/photo-1572635196237-14b3f281503f?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "hydra-bottle",
    name: "Hydra Steel Bottle",
    category: "everyday",
    price: "$32",
    rating: 4.4,
    reviews: "1,870",
    image:
      "https://images.unsplash.com/photo-1602143407151-7111542de6e8?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "field-cap",
    name: "Field Cap",
    category: "everyday",
    price: "$28",
    rating: 4.3,
    reviews: "404",
    image:
      "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?q=80&w=800&auto=format&fit=crop",
  },
  {
    id: "pioneer-camera",
    name: "Pioneer Instant Camera",
    category: "travel",
    price: "$179",
    rating: 4.7,
    reviews: "689",
    badge: "New",
    image:
      "https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?q=80&w=800&auto=format&fit=crop",
  },
];

const CATEGORY_LABELS: Record<Category, string> = {
  audio: "Audio",
  wearables: "Wearables",
  travel: "Travel",
  everyday: "Everyday",
};

export default function Ecommerce01() {
  const [filter, setFilter] = React.useState<Category | "all">("all");
  const [saved, setSaved] = React.useState<readonly string[]>([]);

  const visible =
    filter === "all" ? PRODUCTS : PRODUCTS.filter((p) => p.category === filter);

  const toggleSaved = (id: string) =>
    setSaved((prev) =>
      prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
    );

  return (
    <section className="bg-background py-20 sm:py-28">
      <div className="container flex w-full flex-col gap-10">
        <div className="flex flex-col gap-4">
          <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-foreground">
            shop
          </span>
          <h2 className="max-w-xl font-serif text-4xl font-medium leading-[1.04] tracking-tight sm:text-5xl md:text-6xl">
            The everyday carry edit.
          </h2>
          <p className="max-w-xl text-sm text-muted-foreground">
            Eight pieces we restock on purpose. Free shipping over $200, free
            returns for 60 days.
          </p>
        </div>

        <div className="flex flex-wrap items-center justify-between gap-3 border-y border-border py-3">
          <div className="flex flex-wrap items-center gap-1.5">
            {FILTERS.map((f) => (
              <Button
                key={f.value}
                variant={filter === f.value ? "default" : "outline"}
                size="sm"
                onClick={() => setFilter(f.value)}
                aria-pressed={filter === f.value}
              >
                {f.label}
              </Button>
            ))}
          </div>
          <span className="font-mono text-[10px] tabular-nums uppercase tracking-[0.1em] text-muted-foreground">
            {visible.length} product{visible.length === 1 ? "" : "s"}
          </span>
        </div>

        <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:gap-x-6 lg:grid-cols-4">
          {visible.map((p) => {
            const isSaved = saved.includes(p.id);
            return (
              <article key={p.id} className="group flex flex-col">
                <div className="relative overflow-hidden rounded-md border border-border bg-muted">
                  <div className="relative aspect-square">
                    <Image
                      src={p.image}
                      alt={p.name}
                      fill
                      sizes="(min-width: 1024px) 25vw, 50vw"
                      className="object-cover transition-transform duration-300 ease-out group-hover:scale-105"
                    />
                  </div>
                  {p.badge && (
                    <Badge
                      variant="outline"
                      className="absolute start-2.5 top-2.5 bg-background/85 font-mono text-[10px] uppercase tracking-[0.08em] backdrop-blur"
                    >
                      {p.badge}
                    </Badge>
                  )}
                  <button
                    type="button"
                    onClick={() => toggleSaved(p.id)}
                    aria-pressed={isSaved}
                    aria-label={
                      isSaved
                        ? `Remove ${p.name} from wishlist`
                        : `Add ${p.name} to wishlist`
                    }
                    className="absolute end-2.5 top-2.5 inline-flex size-7 items-center justify-center rounded-full border border-border bg-background/85 text-foreground backdrop-blur transition-colors hover:border-foreground"
                  >
                    <Heart
                      className={`size-3.5 ${isSaved ? "fill-current" : ""}`}
                    />
                  </button>
                </div>

                <div className="flex flex-col gap-1 pt-3">
                  <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                    {CATEGORY_LABELS[p.category]}
                  </span>
                  <h3 className="text-sm font-medium tracking-[-0.01em]">
                    {p.name}
                  </h3>
                  <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
                    <Rating
                      value={p.rating}
                      step={0.5}
                      readOnly
                      size="sm"
                      aria-label={`Rated ${p.rating} out of 5`}
                    />
                    <span className="font-mono tabular-nums">
                      ({p.reviews})
                    </span>
                  </span>
                  <div className="mt-2 flex items-center justify-between gap-2">
                    <span className="flex items-baseline gap-1.5">
                      <span className="font-mono text-sm font-semibold tabular-nums">
                        {p.price}
                      </span>
                      {p.compareAt && (
                        <span className="font-mono text-xs tabular-nums text-muted-foreground line-through">
                          {p.compareAt}
                        </span>
                      )}
                    </span>
                    <Button variant="outline" size="sm">
                      <Plus className="size-3.5" />
                      Add
                    </Button>
                  </div>
                </div>
              </article>
            );
          })}
        </div>
      </div>
    </section>
  );
}

Dependencies

shadcn registry

badgebuttonrating

npm

lucide-react