Order summary

Checkout sidebar card with quantity-badged thumbs, an expandable discount code and a display-face total.

componentsummaryanyorder-summarydiscountshipping-thresholdtax
$npx shadcn@latest add @storekit/checkout-summary
checkout-summary

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/checkout-summary
$pnpm dlx shadcn@latest add @storekit/checkout-summary
$yarn dlx shadcn@latest add @storekit/checkout-summary
$bunx --bun shadcn@latest add @storekit/checkout-summary

Source

components/storekit/checkout/checkout-summary.tsx
152 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ChevronDown, Lock } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { Input } from "@/components/storekit/ui/input"
import { ProductImage } from "@/components/storekit/ui/product-image"

/* Order summary card for the checkout sidebar: lines with a quantity badge on
   the thumb, an expandable discount code, and subtotal, shipping, tax and a
   display-face total. */

type SummaryLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number }

const SAMPLE: SummaryLine[] = [
  { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 2 },
  { id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1 },
  { id: "magnesium-night", title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.webp", quantity: 1 },
]

export function CheckoutSummary({
  items = SAMPLE,
  freeShippingThreshold = 7500,
  shippingCost = 800,
  taxRate = 0.08,
  discountCodes = { WELCOME10: 10 },
  className,
}: {
  items?: SummaryLine[]
  /** In cents. */
  freeShippingThreshold?: number
  /** In cents, charged below the threshold. */
  shippingCost?: number
  taxRate?: number
  /** Code to percent off. */
  discountCodes?: Record<string, number>
  className?: string
}) {
  const reduce = useReducedMotion()
  const [open, setOpen] = React.useState(false)
  const [code, setCode] = React.useState("")
  const [error, setError] = React.useState<string>()
  const [applied, setApplied] = React.useState<{ code: string; pct: number } | null>(null)
  const regionId = React.useId()

  const subtotal = items.reduce((n, l) => n + l.price * l.quantity, 0)
  const discount = applied ? Math.round((subtotal * applied.pct) / 100) : 0
  const net = subtotal - discount
  const shipping = net >= freeShippingThreshold ? 0 : shippingCost
  const tax = Math.round(net * taxRate)
  const total = net + shipping + tax

  const apply = (e: React.FormEvent) => {
    e.preventDefault()
    const key = code.trim().toUpperCase()
    const pct = discountCodes[key]
    if (!key) return setError("Enter a code.")
    if (!pct) return setError("That code is not valid.")
    setApplied({ code: key, pct })
    setError(undefined)
    setCode("")
  }

  return (
    <section aria-label="Order summary" data-sk-section="checkout" data-sk-variant="checkout-summary" className={cn("w-full rounded-sk-card border border-sk-line bg-sk-card p-5 sm:p-6", className)}>
      <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">Order summary</p>
      <ul className="mt-4 grid grid-cols-1 gap-4">
        {items.map((l) => (
          <li key={l.id} className="flex items-center gap-3">
            <div className="relative shrink-0">
              <ProductImage src={l.image} alt={l.title} className="h-16 w-16 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
              <span className="absolute -right-2 -top-2 grid h-5 min-w-5 place-items-center rounded-full bg-sk-ink px-1.5 text-[11px] font-semibold tabular-nums leading-none text-sk-paper">
                <span className="sr-only">Quantity </span>
                {l.quantity}
              </span>
            </div>
            <div className="min-w-0 flex-1">
              <p className="line-clamp-2 break-words text-sm font-medium">{l.title}</p>
              {l.variant ? <p className="text-sm text-sk-muted">{l.variant}</p> : null}
            </div>
            <span className="shrink-0 text-sm font-medium tabular-nums text-sk-price">{money(l.price * l.quantity)}</span>
          </li>
        ))}
      </ul>

      <div className="mt-5 border-y border-sk-line">
        <button
          type="button"
          aria-expanded={open}
          aria-controls={regionId}
          onClick={() => setOpen((o) => !o)}
          className="flex h-12 w-full items-center justify-between text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent"
        >
          {applied ? `Code ${applied.code} applied` : "Discount code"}
          <ChevronDown size={16} className={cn("text-sk-muted transition-transform duration-200", open && "rotate-180")} />
        </button>
        <AnimatePresence initial={false}>
          {open ? (
            <motion.div
              id={regionId}
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{ duration: reduce ? 0 : 0.28, ease: [0.16, 1, 0.3, 1] }}
              className="overflow-hidden"
            >
              <form onSubmit={apply} className="flex items-start gap-2 px-px pb-4">
                <div className="flex-1">
                  <Input aria-label="Discount code" placeholder="WELCOME10" value={code} onChange={(e) => setCode(e.target.value)} error={error} />
                </div>
                <Button type="submit" variant="outline">Apply</Button>
              </form>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>

      <dl className="mt-4 grid gap-2 text-sm">
        <div className="flex justify-between">
          <dt className="text-sk-muted">Subtotal</dt>
          <dd className="tabular-nums">{money(subtotal)}</dd>
        </div>
        {applied ? (
          <div className="flex justify-between text-sk-savings">
            <dt>Discount ({applied.pct}%)</dt>
            <dd className="tabular-nums">-{money(discount)}</dd>
          </div>
        ) : null}
        <div className="flex justify-between">
          <dt className="text-sk-muted">Shipping</dt>
          <dd className={cn("tabular-nums", shipping === 0 && "font-medium text-sk-savings")}>{shipping === 0 ? "Free" : money(shipping)}</dd>
        </div>
        <div className="flex justify-between">
          <dt className="text-sk-muted">Estimated tax</dt>
          <dd className="tabular-nums">{money(tax)}</dd>
        </div>
        <div className="mt-2 flex items-baseline justify-between border-t border-sk-line pt-4">
          <dt className="font-medium">Total</dt>
          <dd className="font-sk-display text-2xl font-semibold tabular-nums tracking-[-0.02em] text-sk-price">{money(total)}</dd>
        </div>
      </dl>

      <p className="mt-5 flex items-center justify-center gap-2 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">
        <Lock size={12} />
        Secure checkout
      </p>
    </section>
  )
}

Usage

Import from "@/components/storekit/checkout/checkout-summary" and pass your product data. The demo above exercises the full prop surface with sample values.

Order summary, StoreKit