ecomcn
All blocks

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}.json

Step 2Add the block

$ npx shadcn@latest add @ecomcn/order-summary
Preview — live
100%

Props

Read straight out of the source at build time, so this cannot drift from the file you install.

OrderSummaryProps
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

Source

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.ts
import * 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])
}