Cart & checkout/block
Order summary
Free-shipping progress, promo code field with applied state, itemised totals and the checkout CTA. Totals logic lives in a separate hook you can test.
Step 1Register the namespace — once per project
$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.jsonStep 2Add the block
$ npx shadcn@latest add @ecomcn/order-summaryPreview — live
100%
Props
Read straight out of the source at build time, so this cannot drift from the file you install.
OrderSummaryPropslines: OrderLine[]
currency?: string
locale?: string
freeShippingThreshold?: number
flatShipping?: number
taxRate?: number
appliedCode?: string | null
discountRate?: number
ctaLabel?: string
/** Reject with an Error whose message is shown inline. */
onApplyCode?: (code: string) => void | Promise<void>
onRemoveCode?: () => void
onCheckout?: () => voidSource
components/ecomcn/order-summary.tsx"use client"
import * as React from "react"
import { Tag, Truck, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useOrderTotals, type OrderLine } from "@/hooks/use-order-totals"
import { cn } from "@/lib/utils"
export interface OrderSummaryProps extends React.HTMLAttributes<HTMLDivElement> {
lines: OrderLine[]
currency?: string
locale?: string
freeShippingThreshold?: number
flatShipping?: number
taxRate?: number
appliedCode?: string | null
discountRate?: number
ctaLabel?: string
/** Reject with an Error whose message is shown inline. */
onApplyCode?: (code: string) => void | Promise<void>
onRemoveCode?: () => void
onCheckout?: () => void
}
export function OrderSummary({
lines,
currency = "USD",
locale,
freeShippingThreshold,
flatShipping = 0,
taxRate = 0,
appliedCode = null,
discountRate = 0,
ctaLabel = "Checkout",
onApplyCode,
onRemoveCode,
onCheckout,
className,
...props
}: OrderSummaryProps) {
const [code, setCode] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const t = useOrderTotals({
lines,
discountRate: appliedCode ? discountRate : 0,
freeShippingThreshold,
flatShipping,
taxRate,
})
const money = React.useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency }),
[locale, currency]
)
const apply = async (event: React.FormEvent) => {
event.preventDefault()
if (!code.trim() || pending) return
setPending(true)
setError(null)
try {
await onApplyCode?.(code.trim())
setCode("")
} catch (e) {
setError(e instanceof Error ? e.message : "That code isn't valid.")
} finally {
setPending(false)
}
}
return (
<div className={cn("space-y-5 border p-5 sm:p-6", className)} {...props}>
<h2 className="text-2xl">Summary</h2>
{freeShippingThreshold !== undefined ? (
<div className="space-y-2 border-y py-4">
<p className="text-[13px]">
{t.toFreeShipping > 0 ? (
<>
<span className="tabular-nums">{money.format(t.toFreeShipping)}</span>{" "}
from free shipping
</>
) : (
<span className="flex items-center gap-1.5 text-[var(--success)]">
<Truck className="size-3.5" aria-hidden /> Free shipping unlocked
</span>
)}
</p>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(t.freeShippingProgress * 100)}
aria-label="Progress toward free shipping"
className="h-1 bg-secondary"
>
<div
className="h-full bg-primary transition-[width] motion-reduce:transition-none"
style={{ width: `${t.freeShippingProgress * 100}%` }}
/>
</div>
</div>
) : null}
{appliedCode ? (
<div className="flex items-center justify-between border border-dashed px-3 py-2.5">
<span className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--success)]">
<Tag className="size-3" aria-hidden /> {appliedCode}
</span>
<button type="button" onClick={onRemoveCode} aria-label={`Remove code ${appliedCode}`}>
<X className="size-3.5 text-muted-foreground hover:text-foreground" />
</button>
</div>
) : onApplyCode ? (
<form onSubmit={apply} className="space-y-1.5">
<div className="flex gap-2">
<Input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="Promo code"
aria-label="Promo code"
aria-invalid={error ? true : undefined}
className="h-10 rounded-none text-[13px]"
/>
<Button type="submit" variant="outline" disabled={pending} className="h-10 text-[13px]">
{pending ? "Checking…" : "Apply"}
</Button>
</div>
{error ? (
<p role="alert" className="text-[12px] text-[var(--sale)]">
{error}
</p>
) : null}
</form>
) : null}
<dl className="space-y-2.5 text-[13px] tabular-nums">
<Row label={`Subtotal (${t.itemCount} items)`} value={money.format(t.subtotal)} />
{t.discount > 0 ? (
<Row label="Discount" value={`−${money.format(t.discount)}`} tone="sale" />
) : null}
<Row
label="Shipping"
value={t.shipping === 0 ? "Free" : money.format(t.shipping)}
tone={t.shipping === 0 ? "success" : undefined}
/>
{taxRate > 0 ? (
<Row label="Estimated tax" value={money.format(t.tax)} muted />
) : null}
</dl>
<div className="flex items-baseline justify-between border-t pt-4">
<span className="text-[11px] uppercase tracking-[0.16em]">Total</span>
<span className="text-3xl tabular-nums">{money.format(t.total)}</span>
</div>
<Button
onClick={onCheckout}
disabled={t.itemCount === 0}
className="h-12 w-full text-[13px] tracking-[0.12em]"
>
{ctaLabel.toUpperCase()}
</Button>
{taxRate > 0 ? (
<p className="text-center text-[11px] text-muted-foreground">
Tax and shipping are estimates until an address is entered.
</p>
) : null}
</div>
)
}
function Row({
label,
value,
muted,
tone,
}: {
label: string
value: string
muted?: boolean
tone?: "sale" | "success"
}) {
return (
<div className="flex justify-between">
<dt className={cn(muted && "text-muted-foreground")}>{label}</dt>
<dd
className={cn(
muted && "text-muted-foreground",
tone === "sale" && "text-[var(--sale)]",
tone === "success" && "text-[var(--success)]"
)}
>
{value}
</dd>
</div>
)
}
hooks/use-order-totals.tsimport * as React from "react"
export interface OrderLine {
id: string
unitPrice: number
quantity: number
}
export interface OrderTotalsOptions {
lines: OrderLine[]
/** 0–1. Applied to the subtotal before shipping and tax. */
discountRate?: number
/** Order value at which shipping becomes free. Omit to disable the meter. */
freeShippingThreshold?: number
flatShipping?: number
/** 0–1. An estimate until a real address exists — say so in the UI. */
taxRate?: number
}
export interface OrderTotals {
itemCount: number
subtotal: number
discount: number
shipping: number
tax: number
total: number
/** Remaining spend to unlock free shipping, or 0 once unlocked. */
toFreeShipping: number
freeShippingProgress: number
}
/**
* Kept out of the component on purpose: totals are the part of a cart that
* actually needs unit tests, and the part every store tweaks.
*/
export function useOrderTotals({
lines,
discountRate = 0,
freeShippingThreshold,
flatShipping = 0,
taxRate = 0,
}: OrderTotalsOptions): OrderTotals {
return React.useMemo(() => {
const itemCount = lines.reduce((n, l) => n + l.quantity, 0)
const subtotal = lines.reduce((n, l) => n + l.unitPrice * l.quantity, 0)
const discount = subtotal * discountRate
const afterDiscount = subtotal - discount
const qualifies =
freeShippingThreshold !== undefined && afterDiscount >= freeShippingThreshold
const shipping = itemCount === 0 || qualifies ? 0 : flatShipping
const tax = afterDiscount * taxRate
return {
itemCount,
subtotal,
discount,
shipping,
tax,
total: afterDiscount + shipping + tax,
toFreeShipping:
freeShippingThreshold === undefined
? 0
: Math.max(0, freeShippingThreshold - afterDiscount),
freeShippingProgress:
freeShippingThreshold === undefined
? 1
: Math.min(1, afterDiscount / freeShippingThreshold),
}
}, [lines, discountRate, freeShippingThreshold, flatShipping, taxRate])
}