Skip to content
6

E-commerce 2

Shopping cart with quantity steppers, removable line items, promo-code validation, a free-shipping threshold, live totals and an empty-cart state.

Preview

Installation

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

Code

components/blocks/ecommerce-02.tsx
'use client';

import * as React from 'react';
import Image from 'next/image';
import { ArrowRight, Minus, Plus, ShoppingBag, X } from 'lucide-react';

import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/registry/hirael/bases/radix/ui/card';
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from '@/registry/hirael/bases/radix/ui/empty';
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from '@/registry/hirael/bases/radix/ui/input-group';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';

interface LineItem {
  id: string;
  name: string;
  variant: string;
  price: number;
  qty: number;
  image: string;
}

const INITIAL_ITEMS: readonly LineItem[] = [
  {
    id: 'atlas-headphones',
    name: 'Atlas Over-Ear Headphones',
    variant: 'Graphite',
    price: 249,
    qty: 1,
    image: '/media/blocks/ecommerce-02/atlas-headphones.jpg',
  },
  {
    id: 'meridian-watch',
    name: 'Meridian Chrono Watch',
    variant: 'Steel · 40mm',
    price: 389,
    qty: 1,
    image: '/media/blocks/ecommerce-02/meridian-watch.jpg',
  },
  {
    id: 'hydra-bottle',
    name: 'Hydra Steel Bottle',
    variant: 'Matte black · 750ml',
    price: 32,
    qty: 2,
    image: '/media/blocks/ecommerce-02/hydra-bottle.jpg',
  },
];

const PROMO_CODE = 'HIRAEL10';
const PROMO_RATE = 0.1;
const FREE_SHIPPING_OVER = 200;
const SHIPPING_FLAT = 12;

const usd = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });

const Ecommerce02 = () => {
  const [items, setItems] = React.useState<readonly LineItem[]>(INITIAL_ITEMS);
  const [code, setCode] = React.useState('');
  const [promoApplied, setPromoApplied] = React.useState(false);
  const [promoError, setPromoError] = React.useState(false);

  const count = items.reduce((sum, i) => sum + i.qty, 0);
  const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
  const discount = promoApplied ? subtotal * PROMO_RATE : 0;
  const shipping = items.length === 0 || subtotal - discount >= FREE_SHIPPING_OVER ? 0 : SHIPPING_FLAT;
  const total = subtotal - discount + shipping;

  const setQty = (id: string, qty: number) =>
    setItems((prev) => prev.map((i) => (i.id === id ? { ...i, qty: Math.max(1, qty) } : i)));

  const removeItem = (id: string) => setItems((prev) => prev.filter((i) => i.id !== id));

  const applyPromo = () => {
    if (code.trim().toUpperCase() === PROMO_CODE) {
      setPromoApplied(true);
      setPromoError(false);
    } else {
      setPromoError(true);
    }
  };

  return (
    <section className="bg-background py-20 sm:py-28">
      <div className="container flex w-full flex-col gap-10">
        <div className="flex flex-wrap items-end justify-between gap-3">
          <div className="flex flex-col gap-4">
            <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-foreground">cart</span>
            <h2 className="font-serif text-4xl font-medium leading-[1.04] tracking-tight sm:text-5xl md:text-6xl">
              Almost yours.
            </h2>
          </div>
          <span className="font-mono text-[10px] tabular-nums uppercase tracking-[0.1em] text-muted-foreground">
            {count} item{count === 1 ? '' : 's'}
          </span>
        </div>

        {items.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <ShoppingBag />
              </EmptyMedia>
              <EmptyTitle>Your cart is empty</EmptyTitle>
              <EmptyDescription>
                Everything you remove ends up here as free shelf space. Refill it with the demo order to keep exploring.
              </EmptyDescription>
            </EmptyHeader>
            <EmptyContent>
              <Button variant="outline" size="sm" onClick={() => setItems(INITIAL_ITEMS)}>
                Restore demo cart
              </Button>
            </EmptyContent>
          </Empty>
        ) : (
          <div className="grid grid-cols-1 gap-10 lg:grid-cols-3">
            <ul className="flex flex-col border-t border-border lg:col-span-2">
              {items.map((item) => (
                <li key={item.id} className="flex items-start gap-4 border-b border-border py-5 sm:gap-5">
                  <div className="relative size-20 shrink-0 overflow-hidden rounded-md border border-border bg-muted sm:size-24">
                    <Image src={item.image} alt={item.name} fill sizes="96px" className="object-cover" />
                  </div>

                  <div className="flex min-w-0 flex-1 flex-col gap-1">
                    <h3 className="truncate text-sm font-medium tracking-[-0.01em]">{item.name}</h3>
                    <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
                      {item.variant}
                    </span>
                    <div className="mt-2 inline-flex w-fit items-center rounded-sm border border-border">
                      <Button
                        variant="ghost"
                        size="icon"
                        className="size-7 rounded-e-none"
                        onClick={() => setQty(item.id, item.qty - 1)}
                        disabled={item.qty <= 1}
                        aria-label={`Decrease quantity of ${item.name}`}
                      >
                        <Minus className="size-3" />
                      </Button>
                      <span className="w-8 text-center font-mono text-xs tabular-nums">{item.qty}</span>
                      <Button
                        variant="ghost"
                        size="icon"
                        className="size-7 rounded-s-none"
                        onClick={() => setQty(item.id, item.qty + 1)}
                        aria-label={`Increase quantity of ${item.name}`}
                      >
                        <Plus className="size-3" />
                      </Button>
                    </div>
                  </div>

                  <div className="flex flex-col items-end gap-2">
                    <Button
                      variant="ghost"
                      size="icon"
                      className="size-7 text-muted-foreground hover:text-foreground"
                      onClick={() => removeItem(item.id)}
                      aria-label={`Remove ${item.name}`}
                    >
                      <X className="size-3.5" />
                    </Button>
                    <span className="font-mono text-sm font-medium tabular-nums">{usd(item.price * item.qty)}</span>
                    {item.qty > 1 && (
                      <span className="font-mono text-[10px] tabular-nums text-muted-foreground">
                        {usd(item.price)} each
                      </span>
                    )}
                  </div>
                </li>
              ))}
            </ul>

            <Card className="h-fit lg:sticky lg:top-6">
              <CardHeader>
                <CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
                  order summary
                </CardDescription>
                <CardTitle className="sr-only">Order summary</CardTitle>
              </CardHeader>
              <CardContent className="flex flex-col gap-3">
                <div className="flex items-center justify-between text-sm">
                  <span className="text-muted-foreground">Subtotal</span>
                  <span className="font-mono tabular-nums">{usd(subtotal)}</span>
                </div>
                <div className="flex items-center justify-between text-sm">
                  <span className="text-muted-foreground">Shipping</span>
                  {shipping === 0 ? (
                    <span className="font-mono text-[10px] uppercase tracking-[0.1em] text-success">Free</span>
                  ) : (
                    <span className="font-mono tabular-nums">{usd(shipping)}</span>
                  )}
                </div>
                {promoApplied && (
                  <div className="flex items-center justify-between text-sm">
                    <span className="inline-flex items-center gap-1.5 text-muted-foreground">
                      Discount · {PROMO_CODE}
                      <Button
                        type="button"
                        variant="outline"
                        size="icon-xs"
                        onClick={() => setPromoApplied(false)}
                        aria-label="Remove promo code"
                        className="size-4 rounded-sm text-muted-foreground"
                      >
                        <X className="size-2.5" />
                      </Button>
                    </span>
                    <span className="font-mono tabular-nums text-success">{usd(discount)}</span>
                  </div>
                )}
                {shipping > 0 && (
                  <p className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
                    {usd(FREE_SHIPPING_OVER - (subtotal - discount))} away from free shipping
                  </p>
                )}

                <Separator className="my-1" />

                <div className="flex items-baseline justify-between">
                  <span className="text-sm font-medium">Total</span>
                  <span className="font-mono text-xl font-semibold tabular-nums tracking-[-0.02em]">{usd(total)}</span>
                </div>

                {!promoApplied && (
                  <div className="flex flex-col gap-1.5">
                    <InputGroup>
                      <InputGroupInput
                        value={code}
                        onChange={(e) => {
                          setCode(e.target.value);
                          setPromoError(false);
                        }}
                        onKeyDown={(e) => {
                          if (e.key === 'Enter') applyPromo();
                        }}
                        placeholder="Promo code"
                        aria-label="Promo code"
                        aria-invalid={promoError}
                        className="font-mono text-xs uppercase"
                      />
                      <InputGroupAddon align="inline-end">
                        <InputGroupButton size="sm" onClick={applyPromo}>
                          Apply
                        </InputGroupButton>
                      </InputGroupAddon>
                    </InputGroup>
                    {promoError ? (
                      <p className="text-xs text-destructive">That code isn&apos;t recognized. Try {PROMO_CODE}.</p>
                    ) : (
                      <p className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
                        Hint · {PROMO_CODE} takes 10% off
                      </p>
                    )}
                  </div>
                )}

                <Button className="mt-1 w-full">
                  Checkout · {usd(total)}
                  <ArrowRight className="size-4 rtl:rotate-180" />
                </Button>
                <p className="text-center font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
                  Free returns · 2-year warranty · secure checkout
                </p>
              </CardContent>
            </Card>
          </div>
        )}
      </div>
    </section>
  );
};

export default Ecommerce02;

Dependencies

shadcn registry

buttoncardemptyinput-groupseparator

npm

lucide-react