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 · lightRegistry 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,
}
}