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/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'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