# React order summary

_Box Kite 2.2.4 · a markdown copy of https://www.box-kite.dev/blocks/react-order-summary/_

A React order summary: subtotal, discount, shipping, tax and total from the cart, a discount code field and a free-shipping progress bar.

## Install

One command copies the files into your project, as code you then own. It needs a `components.json`, and [the shadcn registry page](https://www.box-kite.dev/registry.md) has the one-time setup.

```shell
npx shadcn@latest add https://www.box-kite.dev/r/order-summary.json
```

[Open in v0](https://v0.dev/chat/api/open?url=https%3A%2F%2Fwww.box-kite.dev%2Fr%2Forder-summary.json)

[The item JSON](https://www.box-kite.dev/r/order-summary.json)

1 file · installs @box-kite/react, store-design

## Light and dark

The same component twice, each half in a theme of its own — nothing in it is written for either. [Open it full screen](https://www.box-kite.dev/blocks/react-order-summary/demo.md) to see it in yours.

## Source

The files the command writes, exactly as the preview above runs them. Every style is a prop, so there is no stylesheet to copy with them.

**box-kite/store/order-summary.tsx**

```jsx
'use client';
import Box from '@box-kite/react';
import Button from '@box-kite/react/components/button';
import Flex from '@box-kite/react/components/flex';
import Progress from '@box-kite/react/components/progress';
import { Aside, H2, Label, Li, Span, Ul } from '@box-kite/react/components/semantics';
import Textbox from '@box-kite/react/components/textbox';
import { type FormEvent, type ReactNode, useId, useState } from 'react';
import ProductArt from './product-art';
import { type CartLine, FREE_SHIPPING_FROM, formatPrice, finishes, productBySlug, subtotalOf, TAX_RATE } from './store-data';
import { heading } from './store-design';

/** The one code the demo accepts. Check codes on your server; this is only the shape of the round trip. */
const CODES: Record<string, number> = { STILL10: 0.1 };

export interface OrderSummaryProps {
  lines: readonly CartLine[];
  /** The shipping price once a method is chosen. Left out, the summary says it is worked out at checkout. */
  shipping?: number;
  /** List the objects themselves — what a checkout page shows, where the cart is no longer on screen. */
  showLines?: boolean;
  /** The button underneath: "Checkout" on the cart page, nothing on the checkout. */
  action?: ReactNode;
  /** Told the discount once a code is accepted, for a page that shows the total somewhere else too. */
  onDiscountChange?: (amount: number) => void;
}

/**
 * Subtotal, discount, shipping, tax and the total, with a discount-code field and how far the cart is from
 * free shipping. Every figure is computed from `lines`, so it can never disagree with the cart beside it.
 */
export default function OrderSummary({ lines, shipping, showLines, action, onDiscountChange }: OrderSummaryProps) {
  const [code, setCode] = useState('');
  const [applied, setApplied] = useState<string>();
  const [error, setError] = useState<string>();
  const ids = { heading: useId(), code: useId(), error: useId() };

  const subtotal = subtotalOf(lines);
  const discount = applied ? subtotal * CODES[applied] : 0;
  const tax = (subtotal - discount) * TAX_RATE;
  const total = subtotal - discount + (shipping ?? 0) + tax;
  const toFree = Math.max(FREE_SHIPPING_FROM - subtotal, 0);

  const apply = (event: FormEvent) => {
    event.preventDefault();
    const normalised = code.trim().toUpperCase();

    if (CODES[normalised]) {
      setApplied(normalised);
      onDiscountChange?.(subtotal * CODES[normalised]);
      setError(undefined);
    } else {
      setError(normalised ? `“${code.trim()}” is not a code we recognise.` : 'Enter a code first.');
    }
  };

  return (
    <Aside props={{ 'aria-labelledby': ids.heading }} p={6} borderRadius={4} bgColor="stone-100" theme={{ dark: { bgColor: 'stone-900' } }}>
      <H2 id={ids.heading} fontSize={18} {...heading}>
        Order summary
      </H2>

      {showLines && (
        <Ul listStyle="none" m={0} p={0} mt={5} display="flex" d="column" gap={4}>
          {lines.map((line) => {
            const product = productBySlug(line.slug);

            return (
              <Li key={line.id} display="flex" ai="center" gap={3}>
                <Box position="relative" flexShrink={0} width={14} height={14}>
                  <ProductArt shape={product.shape} finish={line.finish} stroke={1.5} radius={2} />
                  <Span
                    position="absolute"
                    top={-1.5}
                    insetEnd={-1.5}
                    minWidth={5}
                    height={5}
                    px={1}
                    display="flex"
                    ai="center"
                    jc="center"
                    borderRadius={999}
                    fontSize={11}
                    fontWeight={700}
                    bgColor="stone-700"
                    color="white"
                    props={{ 'aria-hidden': true }}
                  >
                    {line.quantity}
                  </Span>
                </Box>
                <Flex d="column" flex1 minWidth={0} fontSize={14}>
                  <Span fontWeight={500}>
                    {product.name}
                    {line.quantity > 1 && <Span display="inline" fontWeight={400}>{` × ${line.quantity}`}</Span>}
                  </Span>
                  <Span color="stone-600" theme={{ dark: { color: 'stone-400' } }}>
                    {[finishes[line.finish].name, line.size].filter(Boolean).join(' · ')}
                  </Span>
                </Flex>
                <Span fontSize={14} fontWeight={500}>
                  {formatPrice(product.price * line.quantity)}
                </Span>
              </Li>
            );
          })}
        </Ul>
      )}

      <Box tag="form" mt={5} props={{ onSubmit: apply, noValidate: true }}>
        <Label display="block" fontSize={14} fontWeight={500} mb={2} props={{ htmlFor: ids.code }}>
          Discount code
        </Label>
        <Flex gap={2}>
          <Textbox
            id={ids.code}
            value={code}
            onChange={(event) => setCode(event.target.value)}
            placeholder="Try STILL10"
            props={{ autoComplete: 'off', 'aria-invalid': error ? true : undefined, 'aria-describedby': error ? ids.error : undefined }}
          />
          <Button type="submit" variant="secondary" flexShrink={0}>
            Apply
          </Button>
        </Flex>
        {error && (
          <Span id={ids.error} display="block" mt={2} fontSize={13} color="red-700" theme={{ dark: { color: 'red-400' } }}>
            {error}
          </Span>
        )}
      </Box>

      <Box tag="dl" mt={5} display="flex" d="column" gap={3} fontSize={15}>
        <Row term="Subtotal" value={formatPrice(subtotal)} />
        {applied && <Row term={`Discount (${applied})`} value={`−${formatPrice(discount)}`} accent />}
        <Row term="Shipping" value={shipping === undefined ? 'At checkout' : shipping === 0 ? 'Free' : formatPrice(shipping)} />
        <Row term="Estimated tax" value={formatPrice(tax)} />
        <Flex jc="space-between" ai="baseline" pt={4} mt={1} bt={1} borderColor="stone-200" theme={{ dark: { borderColor: 'stone-800' } }}>
          <Box tag="dt" fontWeight={500}>
            Total
          </Box>
          <Box tag="dd" m={0} fontSize={22} fontWeight={500}>
            {formatPrice(total)}
          </Box>
        </Flex>
      </Box>

      {shipping === undefined && (
        <Box mt={5}>
          <Progress label="Progress towards free shipping" value={Math.min(subtotal / FREE_SHIPPING_FROM, 1) * 100} />
          <Span display="block" mt={2} fontSize={13} color="stone-600" theme={{ dark: { color: 'stone-400' } }}>
            {toFree ? `${formatPrice(toFree)} more and standard shipping is free.` : 'Your order ships free.'}
          </Span>
        </Box>
      )}

      {action && <Box mt={6}>{action}</Box>}
    </Aside>
  );
}

function Row({ term, value, accent }: { term: string; value: string; accent?: boolean }) {
  return (
    <Flex jc="space-between" gap={4} color={accent ? 'green-700' : undefined} theme={accent ? { dark: { color: 'green-400' } } : undefined}>
      <Box tag="dt" color={accent ? undefined : 'stone-600'} theme={accent ? undefined : { dark: { color: 'stone-400' } }}>
        {term}
      </Box>
      <Box tag="dd" m={0} fontWeight={500}>
        {value}
      </Box>
    </Flex>
  );
}
```

It stands on `store-design`, which the command installs with it. The design the store blocks and pages share: the components restyled with Box.createComponents(), the catalogue, the drawn product art, and the header and footer. [The design is on the template page.](https://www.box-kite.dev/templates/react-shopping-cart.md#design)

## Used in

- [React shopping cart page template](https://www.box-kite.dev/templates/react-shopping-cart.md)
- [React checkout page template](https://www.box-kite.dev/templates/react-checkout-page.md)

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

