Skip to content
6

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/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Rating } from '@/registry/hirael/bases/radix/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' },
];

interface 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: '/media/blocks/ecommerce-01/atlas-headphones.jpg',
  },
  {
    id: 'meridian-watch',
    name: 'Meridian Chrono Watch',
    category: 'wearables',
    price: '$389',
    compareAt: '$460',
    rating: 4.9,
    reviews: '318',
    badge: '−15%',
    image: '/media/blocks/ecommerce-01/meridian-watch.jpg',
  },
  {
    id: 'volt-runners',
    name: 'Volt Runner Sneakers',
    category: 'everyday',
    price: '$129',
    rating: 4.6,
    reviews: '942',
    badge: 'New',
    image: '/media/blocks/ecommerce-01/volt-runners.jpg',
  },
  {
    id: 'carryall-pack',
    name: 'Carryall Day Pack',
    category: 'travel',
    price: '$96',
    rating: 4.7,
    reviews: '566',
    image: '/media/blocks/ecommerce-01/carryall-pack.jpg',
  },
  {
    id: 'horizon-sunglasses',
    name: 'Horizon Sunglasses',
    category: 'everyday',
    price: '$74',
    compareAt: '$92',
    rating: 4.5,
    reviews: '211',
    badge: '−20%',
    image: '/media/blocks/ecommerce-01/horizon-sunglasses.jpg',
  },
  {
    id: 'hydra-bottle',
    name: 'Hydra Steel Bottle',
    category: 'everyday',
    price: '$32',
    rating: 4.4,
    reviews: '1,870',
    image: '/media/blocks/ecommerce-01/hydra-bottle.jpg',
  },
  {
    id: 'field-cap',
    name: 'Field Cap',
    category: 'everyday',
    price: '$28',
    rating: 4.3,
    reviews: '404',
    image: '/media/blocks/ecommerce-01/field-cap.jpg',
  },
  {
    id: 'pioneer-camera',
    name: 'Pioneer Instant Camera',
    category: 'travel',
    price: '$179',
    rating: 4.7,
    reviews: '689',
    badge: 'New',
    image: '/media/blocks/ecommerce-01/pioneer-camera.jpg',
  },
];

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

const 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"
                    variant="outline"
                    size="icon-sm"
                    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 size-7 rounded-full bg-background/85 text-foreground backdrop-blur"
                  >
                    <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>
  );
};

export default Ecommerce01;

Dependencies

shadcn registry

badgebuttonrating

npm

lucide-react