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.jsonCode
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/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/hirael/ui/card";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/registry/hirael/ui/empty";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/registry/hirael/ui/input-group";
import { Separator } from "@/registry/hirael/ui/separator";
type 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:
"https://images.unsplash.com/photo-1505740420928-5e560c06d30e?q=80&w=400&auto=format&fit=crop",
},
{
id: "meridian-watch",
name: "Meridian Chrono Watch",
variant: "Steel · 40mm",
price: 389,
qty: 1,
image:
"https://images.unsplash.com/photo-1523275335684-37898b6baf30?q=80&w=400&auto=format&fit=crop",
},
{
id: "hydra-bottle",
name: "Hydra Steel Bottle",
variant: "Matte black · 750ml",
price: 32,
qty: 2,
image:
"https://images.unsplash.com/photo-1602143407151-7111542de6e8?q=80&w=400&auto=format&fit=crop",
},
];
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" });
export default function 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"
onClick={() => setPromoApplied(false)}
aria-label="Remove promo code"
className="inline-flex size-4 items-center justify-center rounded-sm border border-border text-muted-foreground transition-colors hover:border-foreground hover:text-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'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>
);
}
Dependencies
shadcn registry
buttoncardemptyinput-groupseparator
npm
lucide-react