paddle
componentplan-change-previewsource: ok · hit
Plan change preview
Compact plan change summary card with side-by-side plan comparison, proration breakdown, upgrade/downgrade badge, discount, scheduled change warning, and optional confirm/cancel actions. Decision-support view for plan change flows.
npx shadcn add "https://developer.paddle.com/r/plan-change-preview.json"free
Where it goes
testimonial
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
@paddle/paddle-helperscardbadgeskeletonseparatoralert
Files3 · source cached 10/9/2026, 4:38:01 AM
registry/new-york/blocks/plan-change-preview/components/plan-change-preview.tsx
"use client"
import * as React from "react"
import { ArrowRight, AlertCircle } from "lucide-react"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card"
import { Badge } from "@/registry/new-york/ui/badge"
import { Skeleton } from "@/registry/new-york/ui/skeleton"
import { Separator } from "@/registry/new-york/ui/separator"
import { Alert, AlertDescription } from "@/registry/new-york/ui/alert"
import { cn } from "@/lib/utils"
import type { PlanChangePreviewData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import {
formatMoney,
formatDate,
formatBillingCycle,
formatProrationMode,
} from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"
/** Props for the `PlanChangePreview` component. */
export type PlanChangePreviewProps = {
preview?: PlanChangePreviewData
/**
* Paddle proration billing mode passed to `PATCH /subscriptions/{id}/preview`.
* The component derives the billing row label and effective date from this value.
*/
prorationBillingMode?:
| "prorated_immediately"
| "full_immediately"
| "prorated_next_billing_period"
| "full_next_billing_period"
| "do_not_bill"
className?: string
}
export function PlanChangePreview({
preview,
prorationBillingMode,
className,
}: PlanChangePreviewProps) {
if (!preview) {
return <PlanChangePreviewSkeleton className={className} />
}
const {
currency,
currentPlan,
newPlan,
costImpact,
discount,
scheduledChange,
subscriptionStatus,
collectionMode,
} = preview
const isCharge = costImpact.resultDirection === "charge"
const isCredit = costImpact.resultDirection === "credit"
const isNeutral = costImpact.resultDirection === "none"
const isManual = collectionMode === "manual"
const isTrialing = subscriptionStatus === "trialing"
const changeType = isCharge ? "upgrade" : isCredit ? "downgrade" : "change"
const prorationLabel = prorationBillingMode
? formatProrationMode(prorationBillingMode)
: undefined
const isImmediate =
prorationBillingMode?.includes("immediately") || prorationBillingMode === "do_not_bill"
function resolveEffectiveDate(): string | undefined {
if (prorationBillingMode) {
if (isImmediate) return "Immediately"
return costImpact.nextBillDate ? formatDate(costImpact.nextBillDate) : undefined
}
if (costImpact.immediateAmount !== undefined) return "Immediately"
return costImpact.nextBillDate ? formatDate(costImpact.nextBillDate) : undefined
}
const effectiveDate = resolveEffectiveDate()
function resolveScheduledChangeMessage(): string | undefined {
if (!scheduledChange) return undefined
if (scheduledChange.action === "resume") return undefined
const actionLabel = scheduledChange.action === "cancel" ? "Cancellation" : "Pause"
return `${actionLabel} scheduled for ${formatDate(scheduledChange.effectiveAt)}. Billing options may be restricted.`
}
const scheduledChangeMessage = resolveScheduledChangeMessage()
const hasBreakdownRows = costImpact.credit !== undefined || costImpact.charge !== undefined
const totalLabel = isCredit
? "Credit to account"
: isNeutral
? "No charge"
: isManual
? "Invoice amount"
: costImpact.immediateAmount !== undefined
? "Amount due now"
: "Amount at next billing"
const currentIntervalLabel =
formatBillingCycle({
interval: currentPlan.interval,
frequency: currentPlan.billingFrequency ?? 1,
}) ?? currentPlan.interval
const newIntervalLabel =
formatBillingCycle({
interval: newPlan.interval,
frequency: newPlan.billingFrequency ?? 1,
}) ?? newPlan.interval
return (
<Card className={cn("gap-4", className)}>
<CardHeader>
<CardTitle className="text-base font-semibold">Change summary</CardTitle>
<CardDescription>Review the overview of this change</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{scheduledChangeMessage && (
<Alert>
<AlertCircle className="size-4" />
<AlertDescription>{scheduledChangeMessage}</AlertDescription>
</Alert>
)}
<div className="flex items-stretch gap-3">
<div className="flex-1 min-w-0 rounded-lg border bg-muted/40 p-3">
<div className="text-xs text-muted-foreground mb-1">Current plan</div>
<div className="font-medium text-sm truncate">{currentPlan.productName}</div>
<div className="text-muted-foreground text-sm">
{formatMoney(currentPlan.price, currency)}
<span className="text-xs"> / {currentIntervalLabel}</span>
</div>
</div>
<div className="flex items-center shrink-0">
<ArrowRight className="size-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0 rounded-lg border bg-primary/5 border-primary/20 p-3">
<div className="text-xs text-muted-foreground mb-1">New plan</div>
<div className="font-medium text-sm truncate">{newPlan.productName}</div>
<div className="text-muted-foreground text-sm">
{formatMoney(newPlan.price, currency)}
<span className="text-xs"> / {newIntervalLabel}</span>
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Change type</span>
<Badge
variant={
changeType === "upgrade"
? "default"
: changeType === "downgrade"
? "secondary"
: "outline"
}
>
{changeType === "upgrade"
? "Upgrade"
: changeType === "downgrade"
? "Downgrade"
: "Change"}
</Badge>
</div>
{prorationLabel && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Billing</span>
<span className="text-right">{prorationLabel}</span>
</div>
)}
{effectiveDate && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Effective</span>
<span>{effectiveDate}</span>
</div>
)}
{discount && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Discount</span>
<span className="text-right">
<span className="text-success-foreground">{discount.description}</span>
{discount.endsAt && (
<span className="text-muted-foreground text-xs block">
until {formatDate(discount.endsAt)}
</span>
)}
</span>
</div>
)}
</div>
<Separator />
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Current</span>
<span>
{formatMoney(currentPlan.price, currency)}
<span className="text-muted-foreground text-xs"> / {currentIntervalLabel}</span>
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">New</span>
<span>
{formatMoney(newPlan.price, currency)}
<span className="text-muted-foreground text-xs"> / {newIntervalLabel}</span>
</span>
</div>
</div>
{/* Financial summary — credit/charge breakdown + total row */}
{(hasBreakdownRows || !isNeutral) && (
<>
<Separator />
<div className="space-y-1.5">
{costImpact.credit !== undefined && (
<div className="flex items-center justify-between text-sm text-success-foreground">
<span>Credit</span>
<span>−{formatMoney(costImpact.credit, currency)}</span>
</div>
)}
{costImpact.charge !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Charge</span>
<span>{formatMoney(costImpact.charge, currency)}</span>
</div>
)}
<div
className={cn(
"flex items-center justify-between font-medium",
hasBreakdownRows && "pt-1.5 border-t"
)}
>
<span>{totalLabel}</span>
<span className={cn(isCredit && "text-success-foreground")}>
{isCredit ? "−" : ""}
{formatMoney(costImpact.resultAmount, currency)}
</span>
</div>
</div>
</>
)}
{/* Contextual notes derived from subscription state */}
{isTrialing && isNeutral && (
<p className="text-xs text-muted-foreground">
No charges during your trial. Billing begins when your trial ends.
</p>
)}
{isManual && isCharge && (
<p className="text-xs text-muted-foreground">
An invoice will be created for this amount.
</p>
)}
</CardContent>
</Card>
)
}
function PlanChangePreviewSkeleton({ className }: { className?: string }) {
return (
<Card className={cn("gap-4", className)}>
<CardHeader>
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-52" />
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-stretch gap-3">
<div className="flex-1 rounded-lg border bg-muted/40 p-3 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
<div className="flex items-center">
<Skeleton className="h-4 w-4 rounded" />
</div>
<div className="flex-1 rounded-lg border p-3 space-y-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-16" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Separator />
<div className="space-y-1.5">
<div className="flex justify-between">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex justify-between">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-20" />
</div>
</div>
</CardContent>
</Card>
)
}
registry/new-york/blocks/plan-change-preview/components/paddle-plan-change-preview.tsx
"use client"
import * as React from "react"
import { mapPreviewToPlanChangeData } from "@/registry/new-york/blocks/plan-change-preview/lib/plan-change-preview-utils"
import { PlanChangePreview, type PlanChangePreviewProps } from "./plan-change-preview"
type PaddleSubscription = Parameters<typeof mapPreviewToPlanChangeData>[0]
type PreviewResponse = Parameters<typeof mapPreviewToPlanChangeData>[1]
type PaddleDiscount = Parameters<typeof mapPreviewToPlanChangeData>[2]
export type PaddlePlanChangePreviewProps = {
/** Current Paddle Subscription (before the change) */
subscription: PaddleSubscription
/** Subscription update preview response with the proposed changes */
previewResponse: PreviewResponse
/** Paddle Discount for enriched discount display */
discount?: PaddleDiscount
} & Omit<PlanChangePreviewProps, "preview">
/**
* Paddle-aware wrapper for `PlanChangePreview`.
*
* Accepts the current subscription entity and the Paddle preview update response,
* maps them to `PlanChangePreviewData`, and renders the UI component.
*
* Pass `prorationBillingMode` through to control which billing row and label
* the component displays.
*
* `subscription.items[].price.unit_price` must be present for the current plan
* price to display correctly — fetch with full price details, not a list response.
*
* @example
* <PaddlePlanChangePreview
* subscription={subscription}
* previewResponse={previewResponse}
* discount={discount}
* prorationBillingMode="prorated_immediately"
* />
*/
export function PaddlePlanChangePreview({
subscription,
previewResponse,
discount,
...uiProps
}: PaddlePlanChangePreviewProps) {
const preview = mapPreviewToPlanChangeData(subscription, previewResponse, discount)
return <PlanChangePreview preview={preview} {...uiProps} />
}
registry/new-york/blocks/plan-change-preview/lib/plan-change-preview-utils.ts
import type { PlanChangePreviewData } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-types"
import { parseAmount } from "@/registry/new-york/blocks/paddle-helpers/lib/paddle-format"
// ---
// Input shapes — minimal types matching the Paddle Node SDK / API response.
// The preview endpoint (PATCH /subscriptions/{id}/preview) returns a full
// subscription snapshot plus update_summary and transaction previews.
// ---
type AmountWithCurrency = {
amount: string
currencyCode: string
}
type UpdateSummary = {
credit: AmountWithCurrency
charge: AmountWithCurrency
result: {
action: "credit" | "charge"
amount: string
currencyCode: string
}
}
type TransactionPreview = {
billingPeriod?: { startsAt?: string | null } | null
details?: {
totals?: {
grandTotal?: string | null
} | null
} | null
} | null
type RecurringDetails = {
totals: { total: string; currencyCode: string }
lineItems?: Array<{ totals: { total: string } }> | null
}
type PlanItem = {
product: { name: string }
price: {
unitPrice?: { amount: string; currencyCode: string } | null
billingCycle?: { interval: string; frequency: number } | null
}
}
type SubscriptionPreviewResponse = {
currencyCode: string
billingCycle: { interval: string; frequency: number }
updateSummary: UpdateSummary
immediateTransaction?: TransactionPreview
nextTransaction?: TransactionPreview
recurringTransactionDetails: RecurringDetails
items: PlanItem[]
}
// Matches SDK SubscriptionDiscount — subscription.discount only has id + dates
type SubscriptionDiscountField = {
id: string
startsAt?: string | null
endsAt?: string | null
} | null
type PaddleSubscription = {
currencyCode: string
billingCycle: { interval: string; frequency: number }
items: PlanItem[]
status?: "active" | "canceled" | "past_due" | "paused" | "trialing"
collectionMode?: "automatic" | "manual"
scheduledChange?: {
action: "cancel" | "pause" | "resume"
effectiveAt: string
} | null
discount?: SubscriptionDiscountField
}
// Full Discount catalog entity — fetched separately via paddle.discounts.get(id)
type PaddleDiscount = {
id: string
description: string
code?: string | null
type: "flat" | "flat_per_seat" | "percentage"
amount: string
}
/**
* Maps a Paddle subscription preview API response to the `PlanChangePreviewData`
* display contract consumed by `PlanChangePreview`.
*
* Pass `prorationBillingMode` directly to the `<PlanChangePreview>` component as a prop.
* Optionally pass a Paddle `Discount` to enrich the discount display.
*
* @param subscription - Current Paddle Subscription (before the change)
* @param preview - Subscription update preview response
* @param discount - Paddle Discount for enriched discount display
* @returns Mapped preview data for `<PlanChangePreview />`
*
* @example
* const subscription = await paddle.subscriptions.get(subscriptionId)
* const preview = await paddle.subscriptions.previewUpdate(subscriptionId, {
* items: [{ priceId: newPriceId, quantity: 1 }],
* prorationBillingMode: "prorated_immediately",
* })
* const data = mapPreviewToPlanChangeData(subscription, preview)
*/
export function mapPreviewToPlanChangeData(
subscription: PaddleSubscription,
preview: SubscriptionPreviewResponse,
discount?: PaddleDiscount | null
): PlanChangePreviewData {
const currencyCode = preview.currencyCode
const { updateSummary, immediateTransaction, nextTransaction, recurringTransactionDetails } =
preview
const resultAmount = parseAmount(updateSummary.result.amount, currencyCode)
const creditAmount = parseAmount(updateSummary.credit.amount, currencyCode)
const chargeAmount = parseAmount(updateSummary.charge.amount, currencyCode)
const resultDirection: PlanChangePreviewData["costImpact"]["resultDirection"] =
resultAmount === 0 ? "none" : updateSummary.result.action
const immediateAmount = immediateTransaction?.details?.totals?.grandTotal
? parseAmount(immediateTransaction.details.totals.grandTotal, currencyCode)
: undefined
const nextBillAmount = nextTransaction?.details?.totals?.grandTotal
? parseAmount(nextTransaction.details.totals.grandTotal, currencyCode)
: undefined
const nextBillDate = nextTransaction?.billingPeriod?.startsAt ?? undefined
const currentItem = subscription.items[0]
const newItem = preview.items[0]
// description is required by PlanChangePreviewData.discount — only set when full entity provided
const discountOutput: PlanChangePreviewData["discount"] = discount
? {
description: discount.description,
endsAt: subscription.discount?.endsAt ?? undefined,
}
: undefined
const scheduledChange: PlanChangePreviewData["scheduledChange"] = subscription.scheduledChange
? {
action: subscription.scheduledChange.action,
effectiveAt: subscription.scheduledChange.effectiveAt,
}
: undefined
return {
currency: currencyCode,
currentPlan: {
productName: currentItem.product.name,
price: currentItem.price.unitPrice
? parseAmount(currentItem.price.unitPrice.amount, currencyCode)
: 0,
interval: subscription.billingCycle.interval,
billingFrequency: subscription.billingCycle.frequency,
},
newPlan: {
productName: newItem.product.name,
price: parseAmount(recurringTransactionDetails.totals.total, currencyCode),
interval: preview.billingCycle.interval,
billingFrequency: preview.billingCycle.frequency,
},
costImpact: {
resultDirection,
resultAmount,
credit: creditAmount > 0 ? creditAmount : undefined,
charge: chargeAmount > 0 ? chargeAmount : undefined,
immediateAmount,
nextBillAmount,
nextBillDate,
newRecurringTotal: parseAmount(recurringTransactionDetails.totals.total, currencyCode),
newBillingInterval: preview.billingCycle.interval,
newBillingFrequency: preview.billingCycle.frequency,
},
discount: discountOutput,
scheduledChange,
subscriptionStatus: subscription.status,
collectionMode: subscription.collectionMode,
}
}