# Order Summary

> Itemized purchase confirmation with pricing.

A shadcn/ui block from the [@tool-ui](https://www.tool-ui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/tool-ui/order-summary).

- Page, with a live preview: https://shadcn.exchange/r/tool-ui/order-summary
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/tool-ui/order-summary-light-4fe6956e38a2bd0e.png
- Registry item: https://www.tool-ui.com/r/order-summary.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://www.tool-ui.com/r/order-summary.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `zod`
- Registry items: `button`, `separator`, `skeleton`

## Files

### `components/tool-ui/order-summary/_adapter.tsx`

```tsx
/**
 * Adapter: UI and utility re-exports for copy-standalone portability.
 *
 * When copying this component to another project, update these imports
 * to match your project's paths:
 *
 *   cn        → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
 *   Button    → shadcn/ui Button
 *   Separator → shadcn/ui Separator
 *   Skeleton  → shadcn/ui Skeleton
 */

export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export { Separator } from "@/components/ui/separator";
export { Skeleton } from "@/components/ui/skeleton";
```

### `components/tool-ui/order-summary/index.tsx`

```tsx
export { OrderSummary } from "./order-summary";
export type {
  OrderSummaryDisplayProps,
  OrderSummaryReceiptProps,
  OrderSummaryCompoundComponent,
} from "./order-summary";
export {
  type SerializableOrderSummary,
  type OrderSummaryProps,
  type OrderSummaryVariant,
  type OrderItem,
  type Pricing,
  type OrderDecision,
} from "./schema";
```

### `components/tool-ui/order-summary/order-summary.tsx`

```tsx
import { CheckCircle, Package } from "lucide-react";
import type { ReactElement } from "react";
import { cn, Separator } from "./_adapter";
import type {
  OrderSummaryProps,
  OrderItem,
  Pricing,
  OrderDecision,
  OrderSummaryVariant,
} from "./schema";

function formatCurrency(amount: number, currency: string): string {
  try {
    return new Intl.NumberFormat(undefined, {
      style: "currency",
      currency,
    }).format(amount);
  } catch {
    return `${currency} ${amount.toFixed(2)}`;
  }
}

function formatQuantity(quantity: number): string {
  return quantity === 1 ? "" : `Qty: ${quantity}`;
}

function ItemImage({ src, alt }: { src?: string; alt: string }) {
  if (!src) {
    return (
      <div className="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-md">
        <Package
          aria-hidden="true"
          focusable="false"
          className="text-muted-foreground h-5 w-5"
        />
      </div>
    );
  }

  return (
    <img
      src={src}
      alt={alt}
      width={48}
      height={48}
      className="h-12 w-12 shrink-0 rounded-md object-cover"
    />
  );
}

function OrderItemRow({
  item,
  currency,
}: {
  item: OrderItem;
  currency: string;
}) {
  const quantity = item.quantity ?? 1;
  const quantityText = formatQuantity(quantity);
  const hasDescription = item.description || quantityText;
  const lineTotal = item.unitPrice * quantity;

  return (
    <div className="flex gap-3">
      <ItemImage src={item.imageUrl} alt={item.name} />
      <div className="flex min-w-0 flex-1 items-center justify-between">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <div className="flex items-center justify-between">
            <span className="truncate text-sm font-medium">{item.name}</span>
            <span className="truncate text-sm tabular-nums">
              {formatCurrency(lineTotal, currency)}
            </span>
          </div>
          {hasDescription && (
            <div className="text-muted-foreground truncate text-sm">
              {[item.description, quantityText].filter(Boolean).join(" · ")}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function PricingBreakdown({
  pricing,
  className,
}: {
  pricing: Pricing;
  className?: string;
}) {
  const currency = pricing.currency ?? "USD";

  return (
    <dl className={cn("flex flex-col gap-2 text-sm", className)}>
      <div className="flex justify-between gap-4">
        <dt className="text-muted-foreground">Subtotal</dt>
        <dd className="tabular-nums">
          {formatCurrency(pricing.subtotal, currency)}
        </dd>
      </div>

      {pricing.discount !== undefined && pricing.discount > 0 && (
        <div className="flex justify-between gap-4 text-green-600 dark:text-green-500">
          <dt>{pricing.discountLabel || "Discount"}</dt>
          <dd className="tabular-nums">
            -{formatCurrency(pricing.discount, currency)}
          </dd>
        </div>
      )}

      {pricing.shipping !== undefined && (
        <div className="flex justify-between gap-4">
          <dt className="text-muted-foreground">Shipping</dt>
          <dd className="tabular-nums">
            {pricing.shipping === 0
              ? "Free"
              : formatCurrency(pricing.shipping, currency)}
          </dd>
        </div>
      )}

      {pricing.tax !== undefined && (
        <div className="flex justify-between gap-4">
          <dt className="text-muted-foreground">{pricing.taxLabel || "Tax"}</dt>
          <dd className="tabular-nums">
            {formatCurrency(pricing.tax, currency)}
          </dd>
        </div>
      )}

      <div className="flex justify-between gap-4">
        <dt className="font-medium">Total</dt>
        <dd className="font-semibold tabular-nums">
          {formatCurrency(pricing.total, currency)}
        </dd>
      </div>
    </dl>
  );
}

function formatDate(isoString: string): string | undefined {
  try {
    const date = new Date(isoString);
    if (isNaN(date.getTime())) return undefined;
    return date.toLocaleDateString(undefined, {
      month: "short",
      day: "numeric",
      year: "numeric",
    });
  } catch {
    return undefined;
  }
}

function ReceiptBadge({
  orderId,
  confirmedAt,
}: {
  orderId?: string;
  confirmedAt?: string;
}) {
  const formattedDate = confirmedAt ? formatDate(confirmedAt) : undefined;

  const parts = [orderId && `#${orderId}`, formattedDate].filter(Boolean);
  if (parts.length === 0) return null;

  return (
    <p className="text-muted-foreground mt-1 text-sm">{parts.join(" · ")}</p>
  );
}

function OrderSummaryRoot({
  id,
  title = "Order Summary",
  variant,
  items,
  pricing,
  choice,
  className,
}: OrderSummaryProps) {
  const titleId = `${id}-title`;
  const resolvedVariant: OrderSummaryVariant =
    variant ?? (choice === undefined ? "summary" : "receipt");
  const isReceipt = resolvedVariant === "receipt";
  const isMalformedPayload =
    !Array.isArray(items) ||
    items.length === 0 ||
    pricing == null ||
    (isReceipt && choice === undefined);

  if (isMalformedPayload) {
    return (
      <article
        data-slot="order-summary"
        data-tool-ui-id={id}
        aria-labelledby={titleId}
        className={cn("flex max-w-md min-w-80 flex-col gap-3", className)}
      >
        <div className="text-card-foreground rounded-lg border bg-card p-4 shadow-sm">
          <h2 id={titleId} className="text-base font-semibold">
            {title}
          </h2>
          <p className="text-muted-foreground mt-2 text-sm">
            Unable to render order summary
          </p>
        </div>
      </article>
    );
  }

  return (
    <article
      data-slot="order-summary"
      data-tool-ui-id={id}
      aria-labelledby={titleId}
      className={cn("flex max-w-md min-w-80 flex-col gap-3", className)}
    >
      <div
        className={cn(
          "text-card-foreground rounded-lg border shadow-sm",
          isReceipt ? "bg-card/60" : "bg-card",
        )}
      >
        <div className={cn("space-y-4 p-4", isReceipt && "opacity-95")}>
          <div>
            <h2
              id={titleId}
              className="flex items-center gap-2 text-base font-semibold"
            >
              {isReceipt && (
                <CheckCircle
                  aria-hidden="true"
                  focusable="false"
                  className="h-5 w-5 text-green-600 dark:text-green-500"
                />
              )}
              {title}
            </h2>
            {isReceipt && choice && (
              <ReceiptBadge
                orderId={choice.orderId}
                confirmedAt={choice.confirmedAt}
              />
            )}
          </div>

          <div className="space-y-3">
            {items.map((item) => (
              <OrderItemRow
                key={item.id}
                item={item}
                currency={pricing.currency ?? "USD"}
              />
            ))}
          </div>

          <Separator />

          <PricingBreakdown pricing={pricing} />
        </div>
      </div>
    </article>
  );
}

export type OrderSummaryDisplayProps = OrderSummaryProps;

function OrderSummaryDisplay(props: OrderSummaryDisplayProps) {
  return <OrderSummaryRoot {...props} variant="summary" />;
}

export interface OrderSummaryReceiptProps extends Omit<
  OrderSummaryProps,
  "choice"
> {
  choice: OrderDecision;
}

function OrderSummaryReceipt(props: OrderSummaryReceiptProps) {
  return <OrderSummaryRoot {...props} variant="receipt" />;
}

export interface OrderSummaryCompoundComponent {
  (props: OrderSummaryProps): ReactElement;
  Display: (props: OrderSummaryDisplayProps) => ReactElement;
  Receipt: (props: OrderSummaryReceiptProps) => ReactElement;
}

export const OrderSummary: OrderSummaryCompoundComponent = Object.assign(
  OrderSummaryRoot,
  {
    Display: OrderSummaryDisplay,
    Receipt: OrderSummaryReceipt,
  },
);
```

### `components/tool-ui/order-summary/README.md`

```md
# Order Summary

Implementation for the "order-summary" Tool UI surface.

## Files

- public exports: components/tool-ui/order-summary/index.tsx
- serializable schema + parse helpers: components/tool-ui/order-summary/schema.ts

## Companion assets

- Docs page: app/docs/order-summary/content.mdx
- Preset payload: lib/presets/order-summary.ts

## Quick check

Run this after edits:

pnpm test
```

### `components/tool-ui/order-summary/schema.ts`

```ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema";

export const OrderItemSchema = z.object({
  id: z.string(),
  name: z.string(),
  description: z.string().optional(),
  imageUrl: z.string().url().optional(),
  quantity: z.number().int().positive().optional(),
  unitPrice: z.number(),
});

export type OrderItem = z.infer<typeof OrderItemSchema>;

const OrderItemsSchema = z
  .array(OrderItemSchema)
  .min(1)
  .superRefine((items, ctx) => {
    const seenIds = new Set<string>();

    for (const [index, item] of items.entries()) {
      if (seenIds.has(item.id)) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `Duplicate item id: "${item.id}"`,
          path: [index, "id"],
        });
      }

      seenIds.add(item.id);
    }
  });

export const PricingSchema = z.object({
  subtotal: z.number(),
  tax: z.number().optional(),
  taxLabel: z.string().optional(),
  shipping: z.number().optional(),
  discount: z.number().nonnegative().optional(),
  discountLabel: z.string().optional(),
  total: z.number(),
  currency: z.string().optional(),
});

export type Pricing = z.infer<typeof PricingSchema>;

export const OrderSummaryVariantSchema = z.enum(["summary", "receipt"]);
export type OrderSummaryVariant = z.infer<typeof OrderSummaryVariantSchema>;

export const OrderDecisionSchema = z.object({
  action: z.literal("confirm"),
  orderId: z.string().optional(),
  confirmedAt: z.string().datetime().optional(),
});

export type OrderDecision = z.infer<typeof OrderDecisionSchema>;

export const SerializableOrderSummarySchema = z
  .object({
    id: ToolUIIdSchema,
    role: ToolUIRoleSchema.optional(),
    title: z.string().optional(),
    variant: OrderSummaryVariantSchema.optional(),
    items: OrderItemsSchema,
    pricing: PricingSchema,
    choice: OrderDecisionSchema.optional(),
  })
  .strict()
  .superRefine((value, ctx) => {
    if (value.variant === "receipt" && value.choice === undefined) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Receipt variant requires "choice".',
        path: ["choice"],
      });
    }

    if (value.variant === "summary" && value.choice !== undefined) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Summary variant cannot include "choice".',
        path: ["choice"],
      });
    }
  });

export type SerializableOrderSummary = z.infer<
  typeof SerializableOrderSummarySchema
>;

const SerializableOrderSummarySchemaContract = defineToolUiContract(
  "OrderSummary",
  SerializableOrderSummarySchema,
);

export const parseSerializableOrderSummary: (
  input: unknown,
) => SerializableOrderSummary = SerializableOrderSummarySchemaContract.parse;

export const safeParseSerializableOrderSummary: (
  input: unknown,
) => SerializableOrderSummary | null =
  SerializableOrderSummarySchemaContract.safeParse;

export interface OrderSummaryProps extends SerializableOrderSummary {
  className?: string;
}
```

### `components/tool-ui/shared/contract.ts`

```ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";

export interface ToolUiContract<T> {
  schema: z.ZodType<T>;
  parse: (input: unknown) => T;
  safeParse: (input: unknown) => T | null;
}

export function defineToolUiContract<T>(
  componentName: string,
  schema: z.ZodType<T>,
): ToolUiContract<T> {
  return {
    schema,
    parse: (input: unknown) => parseWithSchema(schema, input, componentName),
    safeParse: (input: unknown) => safeParseWithSchema(schema, input),
  };
}
```

### `components/tool-ui/shared/parse.ts`

```ts
import { z } from "zod";

function formatZodPath(path: Array<string | number | symbol>): string {
  if (path.length === 0) return "root";
  return path
    .map((segment) =>
      typeof segment === "number" ? `[${segment}]` : String(segment),
    )
    .join(".");
}

/**
 * Format Zod errors into a compact `path: message` string.
 */
export function formatZodError(error: z.ZodError): string {
  const parts = error.issues.map((issue) => {
    const path = formatZodPath(issue.path);
    return `${path}: ${issue.message}`;
  });

  return Array.from(new Set(parts)).join("; ");
}

/**
 * Parse unknown input and throw a readable error.
 */
export function parseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
  name: string,
): T {
  const res = schema.safeParse(input);
  if (!res.success) {
    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
  }
  return res.data;
}

/**
 * Parse unknown input, returning `null` instead of throwing on failure.
 *
 * Use this in assistant-ui `render` functions where `args` stream in
 * incrementally and may be incomplete until the tool call finishes.
 */
export function safeParseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
): T | null {
  const res = schema.safeParse(input);
  return res.success ? res.data : null;
}
```

### `components/tool-ui/shared/schema.ts`

```ts
import { z } from "zod";
import type { ReactNode } from "react";

/**
 * Tool UI conventions:
 * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
 * - Schema: `SerializableXSchema`
 * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
 * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
 * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
 * - Root attrs: `data-tool-ui-id` + `data-slot`
 */

/**
 * Schema for tool UI identity.
 *
 * Every tool UI should have a unique identifier that:
 * - Is stable across re-renders
 * - Is meaningful (not auto-generated)
 * - Is unique within the conversation
 *
 * Format recommendation: `{component-type}-{semantic-identifier}`
 * Examples: "data-table-expenses-q3", "option-list-deploy-target"
 */
export const ToolUIIdSchema = z.string().min(1);

export type ToolUIId = z.infer<typeof ToolUIIdSchema>;

/**
 * Primary role of a Tool UI surface in a chat context.
 */
export const ToolUIRoleSchema = z.enum([
  "information",
  "decision",
  "control",
  "state",
  "composite",
]);

export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;

export const ToolUIReceiptOutcomeSchema = z.enum([
  "success",
  "partial",
  "failed",
  "cancelled",
]);

export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;

/**
 * Optional receipt metadata: a durable summary of an outcome.
 */
export const ToolUIReceiptSchema = z.object({
  outcome: ToolUIReceiptOutcomeSchema,
  summary: z.string().min(1),
  identifiers: z.record(z.string(), z.string()).optional(),
  at: z.string().datetime(),
});

export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;

/**
 * Base schema for Tool UI payloads (id + optional role/receipt).
 */
export const ToolUISurfaceSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
});

export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;

export const ActionSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  /**
   * Canonical narration the assistant can use after this action is taken.
   *
   * Example: "I exported the table as CSV." / "I opened the link in a new tab."
   */
  sentence: z.string().optional(),
  confirmLabel: z.string().optional(),
  variant: z
    .enum(["default", "destructive", "secondary", "ghost", "outline"])
    .optional(),
  icon: z.custom<ReactNode>().optional(),
  loading: z.boolean().optional(),
  disabled: z.boolean().optional(),
  shortcut: z.string().optional(),
});

export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;

export const DecisionResultSchema = z.object({
  kind: z.literal("decision"),
  version: z.literal(1),
  decisionId: z.string().min(1),
  actionId: z.string().min(1),
  actionLabel: z.string().min(1),
  at: z.string().datetime(),
  payload: z.record(z.string(), z.unknown()).optional(),
});

export type DecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
  payload?: TPayload;
};

export function createDecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
  decisionId: string;
  action: { id: string; label: string };
  payload?: TPayload;
}): DecisionResult<TPayload> {
  return {
    kind: "decision",
    version: 1,
    decisionId: args.decisionId,
    actionId: args.action.id,
    actionLabel: args.action.label,
    at: new Date().toISOString(),
    payload: args.payload,
  };
}

export const ActionButtonsPropsSchema = z.object({
  actions: z.array(ActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
  className: z.string().optional(),
});

export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
  actions: z.array(SerializableActionSchema),
}).omit({ className: true });

export interface ActionsConfig {
  items: Action[];
  align?: "left" | "center" | "right";
  confirmTimeout?: number;
}

export const SerializableActionsConfigSchema = z.object({
  items: z.array(SerializableActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
});

export type SerializableActionsConfig = z.infer<
  typeof SerializableActionsConfigSchema
>;

export type SerializableAction = z.infer<typeof SerializableActionSchema>;
```
