HextaUI

Order Summary

The checkout sidebar with line items, discount codes and totals. Handles estimated taxes, unknown shipping and prices that changed since the cart was built.

Open in new tab

OrderSummary is the column next to a checkout form. It takes a Cart in the same shape the cart block uses and shows each line with a small thumbnail, a quantity badge on the image corner, the variant and the line total. Lists longer than five lines fold behind “Show 3 more”, so the totals stay in view.

Totals come in the order shoppers expect: subtotal, one line per discount with its code as a chip, shipping, taxes and the total with its currency code, “USD $339.78”. Shipping reads “Calculated at next step” until you know it, shows a skeleton while it is being worked out and reads Free when it costs nothing. Taxes are labelled Estimated until you mark them final, with an optional basis such as “Estimated for 94110”. Store sales, discounts and free shipping add up to a “You saved” line.

Discount codes sit behind an “Add discount code” link. Codes are trimmed and uppercased, the Apply button turns into a spinner and then a check, and applied codes become chips you can remove. Automatic discounts show as chips without a remove button. Throw from onApplyCode and your message appears under the field. DiscountCodeError builds the common ones: expired with the date, the amount still needed to reach a minimum, codes that can’t be combined, and codes that don’t exist. The block catches codes that are already applied and the code limit without calling you.

Every amount uses Price, so when a code lands only the digits that changed roll, and nothing counts up on first render. New discount and savings lines grow in before their text fades in. If a line’s price changed since the last render, the row flashes once and keeps a note such as “Price dropped to $58”. With collapsible set, phones get a “Show order summary” bar that keeps the total visible and flashes it when it changes.

  1. Obtenha o HextaUI Pro

    Um único pagamento libera todos os blocos, incluindo os futuros. Ver preços.

  2. Adicione o registro Pro ao components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Adicione seu token

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  4. Adicione o bloco

    pnpm dlx shadcn@latest add @hextaui-pro/order-summary

Shopify discount codes

Send the full list of codes through cartDiscountCodesUpdate, refetch the cart and map it to Cart. Shopify accepts codes it can’t apply and marks them applicable: false, so the example removes those again and shows a specific error instead of a silent no-op.

"use client"

import type { Cart } from "../cart/cart"
import { OrderSummary } from "@/components/blocks/order-summary/order-summary"
import { DiscountCodeError } from "@/components/blocks/order-summary/summary"

type ShopifyMoney = { amount: string; currencyCode: string }

type ShopifyCart = {
  id: string
  discountCodes: { code: string; applicable: boolean }[]
  discountAllocations: {
    discountedAmount: ShopifyMoney
    code?: string
    title?: string
  }[]
  cost: { subtotalAmount: ShopifyMoney; totalTaxAmount: ShopifyMoney | null }
  lines: {
    nodes: {
      id: string
      quantity: number
      cost: { amountPerQuantity: ShopifyMoney; compareAtAmountPerQuantity: ShopifyMoney | null }
      merchandise: {
        id: string
        title: string
        image: { url: string; altText: string | null } | null
        product: { id: string; title: string }
      }
    }[]
  }
}

const mutation = `
  mutation cartDiscountCodesUpdate($cartId: ID!, $discountCodes: [String!]) {
    cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
      cart { id discountCodes { code applicable } }
      userErrors { field message }
    }
  }
`

function toCart(cart: ShopifyCart): Cart {
  return {
    id: cart.id,
    currencyCode: cart.cost.subtotalAmount.currencyCode,
    tax: cart.cost.totalTaxAmount,
    lines: cart.lines.nodes.map((line) => ({
      id: line.id,
      quantity: line.quantity,
      price: line.cost.amountPerQuantity,
      compareAtPrice: line.cost.compareAtAmountPerQuantity,
      merchandise: {
        id: line.merchandise.id,
        productId: line.merchandise.product.id,
        productTitle: line.merchandise.product.title,
        variantTitle: line.merchandise.title,
        image: line.merchandise.image
          ? {
              id: line.merchandise.image.url,
              src: line.merchandise.image.url,
              alt: line.merchandise.image.altText ?? "",
            }
          : undefined,
      },
    })),
    discounts: cart.discountAllocations.map((allocation) => ({
      code: allocation.code ?? allocation.title ?? "Discount",
      label: allocation.title,
      automatic: !allocation.code,
      amount: allocation.discountedAmount,
    })),
  }
}

export function ShopifyOrderSummary({
  cart,
  storefront,
  refetch,
}: {
  cart: ShopifyCart
  storefront: <T>(query: string, variables: Record<string, unknown>) => Promise<T>
  refetch: () => Promise<ShopifyCart>
}) {
  const codes = cart.discountCodes.map((item) => item.code)

  const update = async (discountCodes: string[]) => {
    const result = await storefront<{
      cartDiscountCodesUpdate: { userErrors: { message: string }[] }
    }>(mutation, { cartId: cart.id, discountCodes })
    const error = result.cartDiscountCodesUpdate.userErrors[0]
    if (error) throw new Error(error.message)
    return refetch()
  }

  return (
    <OrderSummary
      cart={toCart(cart)}
      collapsible
      onApplyCode={async (code) => {
        const next = await update([...codes, code])
        const added = next.discountCodes.find((item) => item.code.toUpperCase() === code)
        if (!added?.applicable) {
          await update(codes)
          throw DiscountCodeError.notFound(code)
        }
        return toCart(next)
      }}
      onRemoveCode={async (code) =>
        toCart(await update(codes.filter((item) => item !== code)))
      }
    />
  )
}

Stripe promotion codes

Look up the promotion code on your server and turn Stripe’s restrictions into DiscountCodeError messages: the expiry date, or how much more the shopper needs to spend to reach the minimum amount.

import { fromMinorUnits, toMinorUnits, type Money } from "../price/money"
import { DiscountCodeError } from "@/components/blocks/order-summary/summary"

type StripePromotionCode = {
  code: string
  active: boolean
  expires_at: number | null
  restrictions: { minimum_amount: number | null; minimum_amount_currency: string | null }
  coupon: { percent_off: number | null; amount_off: number | null; valid: boolean }
}

type StripeClient = {
  promotionCodes: {
    list: (params: { code: string; limit: number }) => Promise<{ data: StripePromotionCode[] }>
  }
}

export async function checkPromotionCode(
  stripe: StripeClient,
  code: string,
  subtotal: Money
): Promise<Money> {
  const { data } = await stripe.promotionCodes.list({ code, limit: 1 })
  const promotion = data[0]
  if (!promotion) throw DiscountCodeError.notFound(code)

  if (promotion.expires_at && promotion.expires_at * 1000 < Date.now()) {
    throw DiscountCodeError.expired(code, new Date(promotion.expires_at * 1000))
  }
  if (!promotion.active || !promotion.coupon.valid) {
    throw DiscountCodeError.notFound(code)
  }

  const minimum = promotion.restrictions.minimum_amount
  if (minimum && toMinorUnits(subtotal) < minimum) {
    throw DiscountCodeError.minimum(
      code,
      fromMinorUnits(minimum - toMinorUnits(subtotal), subtotal.currencyCode)
    )
  }

  const { percent_off, amount_off } = promotion.coupon
  const off = percent_off
    ? Math.round((toMinorUnits(subtotal) * percent_off) / 100)
    : (amount_off ?? 0)
  return fromMinorUnits(off, subtotal.currencyCode)
}

With a server action

Next.js hides thrown error messages from server actions in production, so the action returns { ok: false, message } and the client throws it. The message then shows under the field, and a returned cart updates the totals right away.

"use client"

import type { Cart } from "../cart/cart"
import { OrderSummary } from "@/components/blocks/order-summary/order-summary"

type DiscountResult =
  | { ok: true; cart: Cart }
  | { ok: false; message: string }

export function CheckoutSummary({
  cart,
  applyDiscount,
  removeDiscount,
}: {
  cart: Cart
  applyDiscount: (cartId: string, code: string) => Promise<DiscountResult>
  removeDiscount: (cartId: string, code: string) => Promise<DiscountResult>
}) {
  const run = async (result: Promise<DiscountResult>) => {
    const outcome = await result
    if (!outcome.ok) throw new Error(outcome.message)
    return outcome.cart
  }

  return (
    <OrderSummary
      cart={cart}
      shipping="pending"
      tax={{ amount: cart.tax ?? null, estimated: true, basis: "Estimated for your location" }}
      collapsible
      onApplyCode={(code) => run(applyDiscount(cart.id, code))}
      onRemoveCode={(code) => run(removeDiscount(cart.id, code))}
    />
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
OrderSummaryLines, discount code field, totals and the mobile summary bar.
PromoCodeThe discount code link, field, error message and code chips. Works on its own in a cart drawer.
OrderSummarySkeletonA loading placeholder with the same layout.
DiscountCodeErrorAn Error with a reason and helpers for specific messages: expired, minimum, combination, notFound, alreadyApplied, limit.
getSummaryTotalsThe math behind the totals: discounts, shipping, tax, total and savings in minor units.

OrderSummary

Also takes section props.

PropTipoPadrão
cartLines, discounts and currency. Discounts with target "shipping" are listed under shipping.
Cart–
shippingShipping cost. Defaults to cart.shipping, or "pending" when that is empty.
Money | "pending" | "calculating" | "free"–
shippingMethodShown under Shipping once the cost is known, such as “Standard · 3–5 business days”.
string–
taxTaxes. estimated defaults to true. included shows “Including $X in taxes” under the total instead of adding it.
{ amount: Money | null; estimated?: boolean; included?: boolean; calculating?: boolean; basis?: string }–
onApplyCodeApply an uppercased, trimmed code. Throw to show the message under the field. Return a cart to show it before your own state updates.
(code: string) => Promise<Cart | void>–
onRemoveCodeRemove a code. The chip leaves right away and comes back if this throws.
(code: string) => Promise<Cart | void>–
maxCodesHow many codes one order can use.
number5
maxLinesLines shown before the rest fold behind “Show N more”. 0 shows every line.
number4
collapsibleBelow the md breakpoint, collapse the summary behind a bar that shows the total.
booleanfalse
openWhether the mobile summary is expanded, when controlled.
boolean–
defaultOpenWhether the mobile summary starts expanded.
booleanfalse
onOpenChangeCalled when the mobile bar is toggled.
(open: boolean) => void–
localeLocale for money.
Intl.LocalesArgument"en-US"
trailingZerosSet false to show $48 instead of $48.00.
booleantrue
labelsEvery visible and announced string, plus promo for the PromoCode labels.
Partial<OrderSummaryLabels>–

PromoCode

Also takes div props.

PropTipoPadrão
discountsApplied codes and automatic discounts, shown as chips.
CartDiscount[]–
onApplyApply a code. Throw an Error to show its message. A DiscountCodeError with reason "minimum" reads as a hint instead of an error.
(code: string) => unknown–
onRemoveRemove a code. Without it, chips have no remove button.
(code: string) => unknown–
maxCodesHow many codes one order can use.
number5
defaultOpenStart with the field shown instead of the link.
booleanfalse
disabledDisable the link and the field.
booleanfalse
labelsWords for the link, field, button, chips and announcements.
Partial<PromoCodeLabels>–

OrderSummarySkeleton

Also takes div props.

PropTipoPadrão
linesHow many line placeholders to show.
number3
collapsibleMatch a collapsible summary: only the bar shows on phones.
booleanfalse
TeclaAção
EnterIn the code field, apply the code.
EscapeIn an empty code field, close it and return focus to the link.
BackspaceOn a chip’s remove button, remove the code. Focus moves to the next chip.
SpaceEnterOn the summary bar or Show more, expand or collapse.
  • Totals are a description list, so each amount is read with its label. Discounts read “Discount WELCOME10, minus $36.80”, and each line reads its quantity.
  • Code errors are in a role="alert" region linked to the field with aria-describedby and aria-invalid. The text stays in the field and focus returns to it. Applied and removed codes, price changes and the new total are announced politely, the total after it settles.
  • The mobile bar is a button with aria-expanded and aria-controls whose name includes the total. Collapsed content is hidden from screen readers and the tab order.
  • Changes are never shown by color alone: price notes have text and an arrow, and flashes are decorative. With reduced motion, digits change without rolling and rows appear without growing.

Construído com

Os componentes gratuitos do HextaUI de que Order Summary é feito. Cada um é instalado separadamente.

Código

7 arquivos, adicionados a components/blocks/order-summary.