shadimage
paddle
componentcheckout-summarysource: ok · hit

Checkout summary

Order summary for inline checkout: line items, totals, recurring/trial info, and refund policy link. Compliance-ready display for Paddle inline checkout.

npx shadcn add "https://developer.paddle.com/r/checkout-summary.json"
free

Where it goes

checkout

Visual style

playful

Use cases

ecommerce

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

@paddle/paddle-helperscardseparatorskeleton

Files3 · source cached 10/14/2026, 3:34:48 PM

registry/new-york/blocks/checkout-summary/components/checkout-summary.tsx
"use client"

import * as React from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/registry/new-york/ui/card"
import { Separator } from "@/registry/new-york/ui/separator"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import { cn } from "@/lib/utils"
import type { CheckoutSummaryData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import {
  formatMoney,
  formatBillingCycle,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"

/** Props for the `CheckoutSummary` component. */
export type CheckoutSummaryProps = {
  summary?: CheckoutSummaryData
  policyUrl?: string
  policyLabel?: string
  className?: string
}

export function CheckoutSummary({
  summary,
  policyUrl,
  policyLabel = "Refund policy",
  className,
}: CheckoutSummaryProps) {
  if (!summary) {
    return <CheckoutSummarySkeleton className={className} />
  }

  const {
    items,
    subtotal,
    tax,
    total,
    discount,
    currency,
    recurringTotal,
    recurringInterval,
    recurringFrequency,
    trialPeriod,
  } = summary

  const recurringInfo =
    recurringTotal !== undefined && recurringInterval
      ? (() => {
          const cycleLabel = formatBillingCycle({
            frequency: recurringFrequency ?? 1,
            interval: recurringInterval,
          })
          const recurringAmount = formatMoney(recurringTotal, currency)
          return cycleLabel ? `${recurringAmount} / ${cycleLabel}` : recurringAmount
        })()
      : undefined

  return (
    <Card className={cn("gap-4", className)}>
      <CardHeader>
        <CardTitle className="text-base font-semibold">Order summary</CardTitle>
      </CardHeader>

      <CardContent className="flex flex-col gap-4">
        <ul className="space-y-3">
          {items.map((item, index) => (
            <li key={index} className="flex items-start justify-between gap-4 text-sm">
              <div className="min-w-0 flex-1">
                <span className="font-medium">{item.name}</span>
                {item.priceName && (
                  <span className="text-muted-foreground"> — {item.priceName}</span>
                )}
                {item.quantity > 1 && (
                  <span className="text-muted-foreground"> × {item.quantity}</span>
                )}
              </div>
              <span className="shrink-0 tabular-nums">{formatMoney(item.lineTotal, currency)}</span>
            </li>
          ))}
        </ul>

        <Separator />

        <dl className="space-y-2 text-sm">
          <div className="flex justify-between">
            <dt className="text-muted-foreground">Subtotal</dt>
            <dd className="tabular-nums">{formatMoney(subtotal, currency)}</dd>
          </div>

          {discount != null && discount > 0 && (
            <div className="flex justify-between text-success-foreground">
              <dt>Discount</dt>
              <dd className="tabular-nums">−{formatMoney(discount, currency)}</dd>
            </div>
          )}

          <div className="flex justify-between">
            <dt className="text-muted-foreground">Tax</dt>
            <dd className="tabular-nums">{formatMoney(tax, currency)}</dd>
          </div>

          <Separator />

          <div className="flex justify-between font-semibold">
            <dt>Total</dt>
            <dd className="tabular-nums">{formatMoney(total, currency)}</dd>
          </div>
        </dl>

        {(recurringInfo || trialPeriod) && (
          <p className="text-muted-foreground text-xs">
            {trialPeriod && <span>{trialPeriod} free trial. </span>}
            {recurringInfo && <span>Then {recurringInfo}.</span>}
          </p>
        )}

        {policyUrl && (
          <a
            href={policyUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="text-muted-foreground hover:text-foreground text-xs underline transition-colors"
          >
            {policyLabel}
          </a>
        )}
      </CardContent>
    </Card>
  )
}

function CheckoutSummarySkeleton({ className }: { className?: string }) {
  return (
    <Card className={cn("gap-4", className)}>
      <CardHeader>
        <Skeleton className="h-4 w-28" />
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <div className="space-y-3">
          <div className="flex justify-between gap-4">
            <Skeleton className="h-4 w-36" />
            <Skeleton className="h-4 w-16" />
          </div>
          <div className="flex justify-between gap-4">
            <Skeleton className="h-4 w-28" />
            <Skeleton className="h-4 w-16" />
          </div>
        </div>
        <Separator />
        <div className="space-y-2">
          <div className="flex justify-between gap-4">
            <Skeleton className="h-4 w-16" />
            <Skeleton className="h-4 w-14" />
          </div>
          <div className="flex justify-between gap-4">
            <Skeleton className="h-4 w-8" />
            <Skeleton className="h-4 w-14" />
          </div>
          <Separator />
          <div className="flex justify-between gap-4">
            <Skeleton className="h-4 w-10" />
            <Skeleton className="h-4 w-16" />
          </div>
        </div>
        <Skeleton className="h-3 w-48" />
      </CardContent>
    </Card>
  )
}
registry/new-york/blocks/checkout-summary/components/paddle-checkout-summary.tsx
"use client"

import * as React from "react"
import { mapCheckoutEventsToSummary } from "@/registry/new-york/blocks/checkout-summary/lib/checkout-summary-utils"
import { CheckoutSummary, type CheckoutSummaryProps } from "./checkout-summary"

type CheckoutEventsInput = Parameters<typeof mapCheckoutEventsToSummary>[0]

export type PaddleCheckoutSummaryProps = {
  /**
   * Raw `CheckoutEventsData` from a Paddle.js checkout event callback
   * (e.g. `checkout.loaded`, `checkout.updated`).
   */
  checkoutData?: CheckoutEventsInput
} & Omit<CheckoutSummaryProps, "summary">

/**
 * Paddle-aware wrapper for `CheckoutSummary`.
 *
 * Accepts raw `CheckoutEventsData` from Paddle.js checkout event callbacks,
 * maps it to `CheckoutSummaryData`, and renders the order summary UI.
 *
 * Pass `undefined` to render the skeleton loading state.
 *
 * @example
 * const [checkoutData, setCheckoutData] = useState<CheckoutEventsData>()
 *
 * <PaddleCheckoutSummary
 *   checkoutData={checkoutData}
 *   policyUrl="/legal/refunds"
 * />
 */
export function PaddleCheckoutSummary({ checkoutData, ...uiProps }: PaddleCheckoutSummaryProps) {
  const summary = checkoutData ? mapCheckoutEventsToSummary(checkoutData) : undefined
  return <CheckoutSummary summary={summary} {...uiProps} />
}
registry/new-york/blocks/checkout-summary/lib/checkout-summary-utils.ts
import type { CheckoutSummaryData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import { formatTrialPeriod } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"

// ---
// Input shapes — minimal types matching the Paddle.js CheckoutEventsData shape.
// Field names use snake_case (Paddle.js convention). Pass the raw checkout event
// payload or any object matching this shape.
// ---

type TimePeriodLike = {
  interval: string
  frequency: number
}

type CheckoutEventItem = {
  product?: { name?: string | null } | null
  price_name?: string | null
  quantity?: number | null
  totals?: { subtotal?: number | null } | null
  billing_cycle?: TimePeriodLike | null
  trial_period?: TimePeriodLike | null
}

type CheckoutEventsInput = {
  currency_code?: string | null
  items?: CheckoutEventItem[] | null
  totals?: {
    subtotal?: number | null
    tax?: number | null
    total?: number | null
    discount?: number | null
  } | null
  recurring_totals?: {
    total?: number | null
  } | null
}

/**
 * Maps a Paddle.js checkout event payload to the `CheckoutSummaryData`
 * display contract consumed by `CheckoutSummary`.
 *
 * Paddle.js checkout event totals are already decimal numbers (not lowest-denomination),
 * so no cents-to-decimal conversion is needed here. For Paddle API amounts, use
 * `parseAmount()` from `paddle-format` — those are returned in lowest denomination.
 *
 * @param data - Paddle.js checkout event data (e.g. from `checkout.loaded` / `checkout.updated`)
 * @returns Mapped summary data for `<CheckoutSummary />`
 *
 * @example
 * paddle.Update({ eventCallback: (event) => {
 *   const summary = mapCheckoutEventsToSummary(event.data)
 * }})
 */
export function mapCheckoutEventsToSummary(data: CheckoutEventsInput): CheckoutSummaryData {
  const currency = data.currency_code ?? ""

  const items = (data.items ?? []).map((item) => ({
    name: item.product?.name ?? "",
    priceName: item.price_name ?? undefined,
    quantity: item.quantity ?? 1,
    lineTotal: item.totals?.subtotal ?? 0,
  }))

  const totals = data.totals
  const recurringTotals = data.recurring_totals

  const discount = totals?.discount && totals.discount > 0 ? totals.discount : undefined

  // Build recurring billing info from the first item that has a billing cycle.
  // Using find() rather than [0] handles mixed carts (e.g. one-time setup fee
  // followed by a recurring subscription) where the first item may not recur.
  let recurringTotal: number | undefined
  let recurringInterval: string | undefined
  let recurringFrequency: number | undefined
  const firstRecurringItem = data.items?.find((item) => item.billing_cycle != null)
  const billingCycle = firstRecurringItem?.billing_cycle
  if (billingCycle && recurringTotals?.total != null) {
    recurringTotal = recurringTotals.total
    recurringInterval = billingCycle.interval
    recurringFrequency = billingCycle.frequency
  }

  // Build trial period label from the first item that has a trial period.
  let trialPeriod: string | undefined
  const firstTrialItem = data.items?.find((item) => item.trial_period != null)
  if (firstTrialItem?.trial_period) {
    trialPeriod = formatTrialPeriod(firstTrialItem.trial_period)
  }

  return {
    items,
    subtotal: totals?.subtotal ?? 0,
    tax: totals?.tax ?? 0,
    total: totals?.total ?? 0,
    discount,
    currency,
    recurringTotal,
    recurringInterval,
    recurringFrequency,
    trialPeriod,
  }
}

More from paddle

Browse all components in this library →