paddle
libpaddle-helperssource: ok · hit
Paddle helpers
Shared display types, format utilities, and payment method display for Paddle UI components. No SDK dependency — safe to install without @paddle/paddle-js.
npx shadcn add "https://developer.paddle.com/r/paddle-helpers.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files4 · source cached 10/14/2026, 1:09:02 PM
registry/new-york/blocks/paddle-helpers/lib/paddle-types.ts
// ---
// Checkout and pricing types
// Data contracts for checkout and pricing components. Populate from Paddle SDK
// hooks, checkout event callbacks, or any data source with a compatible shape.
// ---
/**
* Formatted price data for display in pricing card components.
*
* Passed as the `priceData` prop to pricing card components.
* All fields are pre-formatted strings, ready to render directly.
*
* Typically populated by `usePaddlePrices`, but you can construct it manually
* or map it from any price data source.
*/
export type PriceData = {
/** Formatted price string, e.g. "$9.99" or "£12.00" */
total: string
/** Pre-discount price, e.g. "$12.99". Present only when a discount is applied. */
originalTotal?: string
/** Billing frequency, e.g. "month", "year", "3 months" */
interval?: string
/** Trial period length, e.g. "7 days", "1 month" */
trialPeriod?: string
}
/**
* Normalized payload delivered to the `onComplete` callback when a Paddle
* checkout transaction completes successfully.
*
* This is a subset of the full Paddle checkout event data, containing the
* fields most commonly needed for post-purchase handling (e.g. sending to
* your backend, showing a confirmation screen).
*/
export type CheckoutCompleteData = {
transactionId: string
customerId: string
customerEmail: string
}
/**
* A single line item in the checkout order summary.
*
* Monetary values are raw decimal numbers — the component formats them using
* the parent `CheckoutSummaryData.currency`.
* Produced by `mapCheckoutEventsToSummary` from `CheckoutEventsData`.
*/
export type CheckoutSummaryItem = {
name: string
priceName?: string
quantity: number
lineTotal: number
}
/**
* Display contract for the `CheckoutSummary` component.
*
* Monetary amounts are raw decimal numbers — the component formats them using `currency`.
* Produced by `mapCheckoutEventsToSummary` from the `CheckoutEventsData` payload
* emitted by Paddle.js checkout events.
*/
export type CheckoutSummaryData = {
items: CheckoutSummaryItem[]
subtotal: number
tax: number
total: number
discount?: number
/** ISO 4217 currency code, e.g. "USD" */
currency: string
/** Recurring total amount as a decimal number */
recurringTotal?: number
/** Recurring billing interval, e.g. "month" */
recurringInterval?: string
/** Recurring billing frequency, e.g. 1 */
recurringFrequency?: number
/** Trial period label, e.g. "7 days" or "1 month" */
trialPeriod?: string
}
// ---
// Subscription display types
// Display contracts for subscription management components. Monetary amounts
// are raw decimal numbers; date values are ISO 8601 strings. Populate from
// Paddle's subscription API responses combined with formatting utilities.
// ---
/** Paddle subscription statuses. From `subscription.status`. */
export type SubscriptionStatus = "active" | "canceled" | "past_due" | "paused" | "trialing"
/**
* A single line item within a subscription.
*
* Represents one product/price combination in the subscription.
* `lineTotal` should come from `recurring_transaction_details.line_items[n].totals.subtotal`
* as a decimal number (e.g. 29.99).
*/
export type SubscriptionStatusItemData = {
/** Product name, e.g. "Pro Plan" */
productName: string
/** Optional product description */
productDescription?: string
/** Optional product image URL */
productImageUrl?: string
/** Price/tier name, e.g. "Monthly" or "Annual". From `items[n].price.name`. */
priceName?: string
quantity: number
/**
* Unit price as a decimal number, e.g. 99.99.
* From `items[n].price.unit_price.amount`. Only rendered when quantity > 1.
*/
unitPrice?: number
/**
* Line item subtotal as a decimal number (before discount, before tax), e.g. 29.99.
* From `recurring_transaction_details.line_items[n].totals.subtotal`.
* Discounts are shown as a separate summary line — this field should NOT include them.
*/
lineTotal: number
}
/**
* Display contract for the `SubscriptionStatusCard` component.
*
* Pass `undefined` to render a skeleton loading state.
* Monetary amounts are raw decimal numbers — the component formats them using `currency`.
* Date values are ISO 8601 strings — the component formats them for display.
*
* `totalAmount` should come from `recurring_transaction_details.totals.total` —
* this is the steady-state recurring amount (after discounts and tax).
*/
export type SubscriptionStatusData = {
/** Paddle subscription ID, e.g. "sub_01abc..." */
id?: string
/** Subscription line items — auto-adapts layout for single vs multi-item */
items: SubscriptionStatusItemData[]
/** Recurring total as a decimal number, e.g. 49.99 */
totalAmount: number
/** ISO 4217 currency code, e.g. "USD" */
currency: string
/** Billing interval, e.g. "month" or "year" */
interval: string
/** Billing frequency. Defaults to 1. E.g. 3 for "every 3 months". */
billingFrequency?: number
status: SubscriptionStatus
/** ISO 8601 subscription start date */
startedAt: string
/** ISO 8601 next billing date. Absent when paused or canceled. */
nextBilledAt?: string
/** ISO 8601 cancellation date. Present only when status is "canceled". */
canceledAt?: string
/**
* Payment collection mode. From `subscription.collection_mode`.
* "automatic" = auto-renew via saved payment method, "manual" = invoiced.
*/
collectionMode?: "automatic" | "manual"
scheduledChange?: {
/** Type of scheduled change */
action: "cancel" | "pause" | "resume"
/** ISO 8601 date when the change takes effect */
effectiveAt: string
/** ISO 8601 date when the subscription resumes, only for pause actions with a set resume date */
resumeAt?: string
}
/** Active discount, if any */
discount?: {
/** Discount savings amount as a decimal number, e.g. 5.00 */
savingsAmount: number
/** ISO 8601 date when the discount expires. Absent if discount recurs forever. */
endsAt?: string
/** Discount code, e.g. "SAVE20". From `discount.code`. */
code?: string
/** Discount description for billing summary, e.g. "-15%" or "-$5.00". API-provided or derived. */
description?: string
}
}
/**
* Display contract for the `SubscriptionAlert` component.
*
* The component derives which alert to render from these fields.
* Date values are ISO 8601 strings — the component formats them for display.
* Pass `undefined` to render nothing.
*
* `updatePaymentMethodUrl` comes from `subscription.management_urls.update_payment_method`.
* `trialEndsAt` comes from `subscription.items[0].trial_dates.ends_at`.
*/
export type SubscriptionAlertData = {
status: SubscriptionStatus
/** ISO 8601 cancellation date. Present when status is "canceled". */
canceledAt?: string
scheduledChange?: {
/** Type of scheduled change */
action: "cancel" | "pause" | "resume"
/** ISO 8601 date when the change takes effect */
effectiveAt: string
/** ISO 8601 date when the subscription resumes, only for pause with a set resume date */
resumeAt?: string
}
/** ISO 8601 trial end date. Present when status is "trialing". */
trialEndsAt?: string
/** Portal deep link to update payment method. Null for manual collection. */
updatePaymentMethodUrl?: string
}
/**
* Next payment details for the `SubscriptionPaymentCard` component.
*
* `amount` should come from `next_transaction.details.totals.grand_total`
* (accounts for credits and adjustments) — pass as a decimal number (e.g. 29.99).
* `currency` should come from `subscription.currency_code`.
* `date` should come from `subscription.next_billed_at` as an ISO 8601 string.
*/
export type NextPaymentData = {
/** Payment amount as a decimal number, e.g. 29.99 */
amount: number
/** ISO 4217 currency code, e.g. "USD" */
currency: string
/** ISO 8601 date string for the next billing date, e.g. "2025-02-01T00:00:00Z" */
date: string
}
/**
* Payment method details for the `SubscriptionPaymentCard` component.
*
* Pass the raw fields from `transaction.payments[0].method_details` directly —
* the component resolves the display label automatically via `getPaymentMethodDisplay`.
*
* Sourced from the most recent completed transaction for the subscription
* (not the saved payment methods API — see spec for sourcing details).
*
* @example
* // From a completed Paddle transaction:
* const payment = transaction.payments[0].methodDetails
* paymentMethod={payment ? {
* type: payment.type,
* cardBrand: payment.card?.type,
* last4: payment.card?.last4,
* } : undefined}
*/
export type PaymentMethodData = {
/** Paddle payment method type from `method_details.type`, e.g. "card", "paypal", "apple_pay" */
type: string
/** Card brand from `method_details.card.type`, e.g. "visa", "mastercard". Card payments only. */
cardBrand?: string
/** Last 4 digits from `method_details.card.last4`, e.g. "4242". Card payments only. */
last4?: string
/** Card expiry month (1–12) from `method_details.card.expiry_month`. Card payments only. */
expiryMonth?: number
/** Card expiry year (4-digit) from `method_details.card.expiry_year`. Card payments only. */
expiryYear?: number
/**
* Optional display label override. When provided, renders as-is instead of
* auto-resolving from `type`/`cardBrand`/`last4`. Use for custom formats
* (e.g. "Visa •••• 4242") or when you've already formatted the label upstream.
*/
label?: string
}
/**
* Display contract for the `PlanChangePreview` component.
*
* Sourced from the current subscription entity and the response from
* `PATCH /subscriptions/{id}/preview`. Pass `undefined` to render a skeleton.
*
* Monetary amounts are raw decimal numbers — the component formats them using `currency`.
* Date values are ISO 8601 strings — the component formats them for display.
* UI labels and contextual messages belong on component props, not here.
*/
export type PlanChangePreviewData = {
/** ISO 4217 currency code, e.g. "USD" */
currency: string
/** The plan being replaced */
currentPlan: {
/** Product name of the current plan */
productName: string
/** Current recurring total as a decimal number, e.g. 9.99 */
price: number
/** Current billing interval, e.g. "month" */
interval: string
/** Current billing frequency, defaults to 1 */
billingFrequency?: number
}
/** The plan being switched to */
newPlan: {
/** Product name of the new plan */
productName: string
/** New recurring total as a decimal number, e.g. 29.99 */
price: number
/** New billing interval, e.g. "month" */
interval: string
/** New billing frequency, defaults to 1 */
billingFrequency?: number
}
/** Financial impact of the plan change */
costImpact: {
/**
* Direction of the net financial impact.
* - `"charge"` — customer owes money (upgrade)
* - `"credit"` — customer receives credit (downgrade)
* - `"none"` — no financial movement (e.g. `do_not_bill` proration mode,
* trial upgrade, or paused subscription change)
*/
resultDirection: "credit" | "charge" | "none"
/** Net financial impact as a decimal number, e.g. 20.00. From `update_summary.result.amount`. */
resultAmount: number
/** Prorated credit for unused time on old plan as a decimal number. From `update_summary.credit.amount`. */
credit?: number
/** Prorated charge for new plan as a decimal number. From `update_summary.charge.amount`. */
charge?: number
/**
* Amount charged immediately as a decimal number.
* Present for `*_immediately` proration modes. From `immediate_transaction.details.totals.grand_total`.
*/
immediateAmount?: number
/**
* Next bill total as a decimal number.
* Present for `*_next_billing_period` proration modes. From `next_transaction.details.totals.grand_total`.
*/
nextBillAmount?: number
/** ISO 8601 next bill date. From `next_transaction.billing_period.starts_at`. */
nextBillDate?: string
/**
* New steady-state recurring total as a decimal number after the change settles.
* From `recurring_transaction_details.totals.total`.
*/
newRecurringTotal: number
/** New billing interval. From `billing_cycle.interval`. */
newBillingInterval: string
/** New billing frequency. From `billing_cycle.frequency`. Defaults to 1. */
newBillingFrequency?: number
}
/** Active discount on the subscription. From `subscription.discount`. */
discount?: {
/** Discount description, e.g. "20% off" or "-$5.00". From `subscription.discount.description`. */
description: string
/** ISO 8601 expiry date. Absent if discount recurs forever. From `subscription.discount.ends_at`. */
endsAt?: string
}
/** Pending scheduled change. From `subscription.scheduled_change`. */
scheduledChange?: {
/** Type of scheduled change */
action: "cancel" | "pause" | "resume"
/** ISO 8601 date when the change takes effect */
effectiveAt: string
}
/**
* Subscription status. From `subscription.status`.
* Used by the component to render contextual messaging — e.g. a trial notice
* when `status === "trialing"` and there is no immediate charge.
*/
subscriptionStatus?: SubscriptionStatus
/**
* Payment collection mode. From `subscription.collection_mode`.
* Used by the component to adapt labels — e.g. "Invoice amount" instead of
* "Amount due now" when `collectionMode === "manual"`.
*/
collectionMode?: "automatic" | "manual"
}
// ---
// Plan change breakdown types
// Display contract for the detailed financial breakdown component.
// Shows per-transaction line items, tax, proration, and totals.
// ---
/**
* A single line item within a transaction preview.
* Monetary amounts are raw decimal numbers — the component formats them
* using the parent `PlanChangeBreakdownData.currency`.
*
* Maps to `details.line_items[]` (immediate/next transaction) or
* `line_items[]` (recurring_transaction_details) in the preview response.
*/
export type PlanChangeLineItemData = {
/** Product name, e.g. "Pro Plan". From `line_items[].product.name`. */
productName: string
/** Item quantity. From `line_items[].quantity`. */
quantity: number
/** Unit price as a decimal number, e.g. 49.00. From `line_items[].unit_totals.subtotal`. */
unitPrice: number
/** Line total as a decimal number, e.g. 26.95. From `line_items[].totals.total`. */
total: number
/** Whether this line item is prorated. Derived from `line_items[].proration !== null`. */
isProrated?: boolean
/** Proration period label, e.g. "15 of 31 days". Derived from `proration.rate` and `proration.billing_period`. */
prorationPeriod?: string
}
/**
* Totals breakdown for a single transaction section.
* All values are raw decimal numbers — the component formats them using
* the parent `PlanChangeBreakdownData.currency`.
*
* Maps to `details.totals` (immediate/next) or `totals` (recurring) in the preview response.
*/
export type PlanChangeTransactionTotalsData = {
/** Subtotal as a decimal number (before discount, tax, and deductions). From `totals.subtotal`. */
subtotal: number
/** Discount amount as a decimal number. Present when a discount is active. From `totals.discount`. */
discount?: number
/** Tax amount as a decimal number. From `totals.tax`. */
tax: number
/** Credit applied to this transaction as a decimal number. From `totals.credit`. */
credit?: number
/** Surplus credit added to customer balance as a decimal number. From `totals.credit_to_balance`. */
creditToBalance?: number
/** Total due as a decimal number (after credits). From `totals.grand_total`. */
total: number
}
/**
* A complete transaction section (immediate, next, or recurring).
* The section's position within `PlanChangeBreakdownData` determines its
* role — the component derives titles and descriptions from that context.
*/
export type PlanChangeTransactionSectionData = {
/**
* ISO 8601 billing date for this transaction period.
* Only relevant for `nextTransaction` — sourced from `billing_period.starts_at`.
* Omitted for immediate and recurring sections.
*/
billingDate?: string
lineItems: PlanChangeLineItemData[]
totals: PlanChangeTransactionTotalsData
}
/**
* Display contract for the `PlanChangeBreakdown` component.
*
* Sourced from `PATCH /subscriptions/{id}/preview`. Pass `undefined` to render a skeleton.
* Shows the full financial detail: per-transaction line items, tax, proration, and totals.
*/
export type PlanChangeBreakdownData = {
/** ISO 4217 currency code, e.g. "USD" */
currency: string
/** Net financial result of the change. From `update_summary.result`. */
result: {
/**
* Direction of the net result.
* - `"charge"` — customer owes money (upgrade)
* - `"credit"` — customer receives credit (downgrade)
* - `"none"` — no financial movement
*/
direction: "charge" | "credit" | "none"
/** Net amount as a decimal number, e.g. 17.45. From `update_summary.result.amount`. */
amount: number
}
/** Credit/charge breakdown from `update_summary` */
breakdown?: {
/** Credit from current plan as a decimal number. From `update_summary.credit`. */
credit?: number
/** Charge for new plan as a decimal number. From `update_summary.charge`. */
charge?: number
}
/**
* Immediate transaction details.
* Present for `*_immediately` proration modes. From `immediate_transaction`.
*/
immediateTransaction?: PlanChangeTransactionSectionData
/** Next billing period transaction details. From `next_transaction`. */
nextTransaction?: PlanChangeTransactionSectionData
/** Recurring (steady-state) billing details. From `recurring_transaction_details`. */
recurringTransaction?: PlanChangeTransactionSectionData
}
registry/new-york/blocks/paddle-helpers/lib/paddle-format.ts
/**
* Formats an ISO 8601 date string as a locale-aware date string.
*
* @param isoString - ISO 8601 date string (e.g. "2024-01-15T00:00:00Z")
* @param locale - Optional BCP 47 locale tag (e.g. "en-GB"). Defaults to "en-US" so SSR
* and client produce identical output (required for React hydration).
* @returns Formatted date string, e.g. "Jan 15, 2024"
*
* @example
* formatDate("2024-01-15T00:00:00Z") // "Jan 15, 2024"
* formatDate("2024-01-15T00:00:00Z", "en-GB") // "15 Jan 2024"
*/
export function formatDate(isoString: string, locale: string = "en-US"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(isoString))
}
// Structural alias — accepts both CheckoutEventsTimePeriod and TimePeriod
type TimePeriodLike = {
frequency: number
interval: string
}
/**
* Formats a billing cycle for display.
* @param billingCycle - The billing cycle to format
* @returns Formatted string like "month", "year", or "3 months", or undefined if no billing cycle
*
* @example
* formatBillingCycle({ frequency: 1, interval: "month" }) // "month"
* formatBillingCycle({ frequency: 3, interval: "month" }) // "3 months"
*/
export function formatBillingCycle(
billingCycle: TimePeriodLike | null | undefined
): string | undefined {
if (!billingCycle) {
return undefined
}
const { frequency, interval } = billingCycle
return frequency === 1 ? interval : `${frequency} ${interval}s`
}
/**
* Formats a trial period for display.
* @param trialPeriod - The trial period to format
* @returns Formatted string like "7 days" or "1 month"
*
* @example
* formatTrialPeriod({ frequency: 7, interval: "day" }) // "7 days"
* formatTrialPeriod({ frequency: 1, interval: "month" }) // "1 month"
*/
export function formatTrialPeriod(trialPeriod: TimePeriodLike): string {
const interval = trialPeriod.frequency === 1 ? trialPeriod.interval : `${trialPeriod.interval}s`
return `${trialPeriod.frequency} ${interval}`
}
/**
* Formats a numeric monetary amount as a localised currency string.
*
* @param amount - Raw numeric amount (e.g. from Paddle checkout event totals)
* @param currencyCode - ISO 4217 currency code (e.g. "USD", "GBP")
* @param locale - Optional BCP 47 locale tag (e.g. "en-GB"). Defaults to "en-US" so SSR
* and client produce identical output (required for React hydration).
* @returns Formatted currency string, e.g. "$29.99" or "£12.00"
*
* @example
* formatMoney(29.99, "USD") // "$29.99"
* formatMoney(12, "GBP", "en-GB") // "£12.00"
*/
export function formatMoney(
amount: number,
currencyCode: string,
locale: string = "en-US"
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: currencyCode,
}).format(amount)
}
// Paddle-supported zero-decimal currencies — amounts are already whole units.
// https://developer.paddle.com/concepts/payment-methods/currencies
const ZERO_DECIMAL_CURRENCIES = new Set(["JPY", "KRW", "VND", "CLP"])
/**
* Parses a Paddle API monetary amount string (lowest denomination, e.g. "1500")
* to a decimal number suitable for `formatMoney` (e.g. 15.00).
*
* Paddle returns amounts as strings in the lowest denomination of the currency.
* Standard currencies (USD, EUR, GBP, etc.) use cents — divide by 100.
* Zero-decimal currencies (JPY, KRW, VND, CLP) are already whole units — no division.
*
* @param raw - Amount string in lowest denomination (e.g. "1500" for $15.00 USD or ¥1500 JPY)
* @param currencyCode - ISO 4217 currency code used to determine decimal handling
* @returns Decimal number ready for `formatMoney`
*
* @example
* parseAmount("1500", "USD") // 15
* parseAmount("999", "USD") // 9.99
* parseAmount("1500", "JPY") // 1500
*/
export function parseAmount(raw: string, currencyCode: string): number {
const value = parseInt(raw, 10)
if (ZERO_DECIMAL_CURRENCIES.has(currencyCode.toUpperCase())) {
return value
}
return value / 100
}
/**
* Returns a human-readable label for a Paddle proration billing mode.
*
* @param mode - Paddle `proration_billing_mode` value
* @returns Display label, e.g. "Charge prorated amount now"
*
* @example
* formatProrationMode("prorated_immediately") // "Charge prorated amount now"
* formatProrationMode("full_next_billing_period") // "Full charge at next billing"
*/
export function formatProrationMode(mode: string): string {
const labels: Record<string, string> = {
prorated_immediately: "Charge prorated amount now",
full_immediately: "Charge full amount now",
prorated_next_billing_period: "Prorated at next billing",
full_next_billing_period: "Full charge at next billing",
do_not_bill: "No charge",
}
return labels[mode] ?? mode
}
const INTERVAL_LABELS: Record<string, { noun: string; adjective: string }> = {
day: { noun: "Daily", adjective: "daily" },
week: { noun: "Weekly", adjective: "weekly" },
month: { noun: "Monthly", adjective: "monthly" },
year: { noun: "Annually", adjective: "annual" },
}
/**
* Returns a human-readable label for a Paddle billing interval.
*
* @param interval - Paddle interval key, e.g. "month", "year"
* @param style - "noun" for toggle labels ("Monthly"), "adjective" for inline labels ("monthly")
* @returns Display label, e.g. "Monthly" or "monthly"
*
* @example
* formatIntervalLabel("month") // "Monthly"
* formatIntervalLabel("year", "adjective") // "annual"
* formatIntervalLabel("month", "noun") // "Monthly"
*/
export function formatIntervalLabel(
interval: string,
style: "noun" | "adjective" = "noun"
): string {
const entry = INTERVAL_LABELS[interval]
if (entry) return entry[style]
return interval.charAt(0).toUpperCase() + interval.slice(1)
}
registry/new-york/blocks/paddle-helpers/lib/paddle-payment-method-display.ts
/**
* Maps Paddle payment method types and card brands to human-readable display labels.
* Sourced from `transaction.payments[0].method_details` on completed transactions.
*/
/** Paddle payment method types from `transaction.payments[0].method_details.type` */
export type PaddlePaymentMethodType =
| "alipay"
| "apple_pay"
| "bancontact"
| "blik"
| "card"
| "google_pay"
| "ideal"
| "kakao_pay"
| "south_korea_local_card"
| "mb_way"
| "naver_pay"
| "offline"
| "payco"
| "paypal"
| "pix"
| "samsung_pay"
| "unknown"
| "upi"
| "wechat_pay"
| "wire_transfer"
/** @deprecated Returned on historical transactions but replaced by `south_korea_local_card` in newer transactions */
| "korea_local"
| (string & {})
/** All Paddle card brand types from `transaction.payments[0].method_details.card.type` */
export type PaddleCardType =
| "american_express"
| "diners_club"
| "discover"
| "jcb"
| "mada"
| "maestro"
| "mastercard"
| "union_pay"
| "unknown"
| "visa"
| (string & {})
const PAYMENT_METHOD_LABELS: Record<string, string> = {
alipay: "Alipay",
apple_pay: "Apple Pay",
bancontact: "Bancontact",
blik: "BLIK",
card: "Card",
google_pay: "Google Pay",
ideal: "iDEAL",
kakao_pay: "Kakao Pay",
south_korea_local_card: "Korea local card",
mb_way: "MB WAY",
naver_pay: "Naver Pay",
offline: "Offline",
payco: "PAYCO",
paypal: "PayPal",
pix: "Pix",
samsung_pay: "Samsung Pay",
unknown: "Payment method",
upi: "UPI",
wechat_pay: "WeChat Pay",
wire_transfer: "Wire transfer",
korea_local: "Korean payment methods",
}
const CARD_BRAND_LABELS: Record<string, string> = {
american_express: "American Express",
diners_club: "Diners Club",
discover: "Discover",
jcb: "JCB",
mada: "Mada",
maestro: "Maestro",
mastercard: "Mastercard",
union_pay: "UnionPay",
unknown: "Card",
visa: "Visa",
}
function formatUnknownType(type: string): string {
return type
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Returns the full display label for a Paddle payment method.
*
* For card payments, pass `cardBrand` (from `method_details.card.type`) to
* get the brand name, and `last4` (from `method_details.card.last4`) to
* append the masked number. Both are optional — the label degrades gracefully.
*
* @param type - Paddle payment method type from `method_details.type`
* @param cardBrand - Card brand from `method_details.card.type` (card payments only)
* @param last4 - Last 4 digits from `method_details.card.last4` (card payments only)
* @returns Human-readable payment method label
*
* @example
* getPaymentMethodDisplay("card", "visa", "4242") // "Visa ending 4242"
* getPaymentMethodDisplay("card", "mastercard") // "Mastercard"
* getPaymentMethodDisplay("card") // "Card"
* getPaymentMethodDisplay("paypal") // "PayPal"
* getPaymentMethodDisplay("apple_pay") // "Apple Pay"
*/
export function getPaymentMethodDisplay(
type: PaddlePaymentMethodType,
cardBrand?: PaddleCardType,
last4?: string
): string {
if (type === "card") {
const brand = cardBrand
? (CARD_BRAND_LABELS[cardBrand] ?? formatUnknownType(cardBrand))
: "Card"
return last4 ? `${brand} ending ${last4}` : brand
}
return PAYMENT_METHOD_LABELS[type] ?? formatUnknownType(type)
}
registry/new-york/blocks/paddle-helpers/lib/payment-method-icons.tsx
import * as React from "react"
import type { PaddlePaymentMethodType, PaddleCardType } from "./paddle-payment-method-display"
// --- Brand icon components ---
function VisaIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 24" className={className} aria-hidden="true">
<rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
<path
d="M16.5 7.5L14.1 16.5H11.7L14.1 7.5H16.5ZM26.4 13.5L27.6 10.1L28.3 13.5H26.4ZM29.1 16.5H31.3L29.4 7.5H27.4C26.9 7.5 26.5 7.8 26.3 8.2L22.8 16.5H25.3L25.8 15.1H28.8L29.1 16.5ZM23.1 13.5C23.1 11.1 19.7 11 19.7 9.9C19.7 9.6 20 9.2 20.7 9.1C21 9.1 22 9 23.1 9.5L23.5 7.8C22.9 7.6 22.1 7.4 21.1 7.4C18.7 7.4 17.1 8.7 17.1 10.5C17.1 11.9 18.4 12.6 19.3 13.1C20.3 13.5 20.6 13.8 20.6 14.2C20.6 14.8 19.9 15.1 19.2 15.1C18 15.1 17.3 14.8 16.7 14.5L16.3 16.3C16.9 16.6 18 16.8 19.1 16.8C21.6 16.8 23.1 15.5 23.1 13.5Z"
fill="currentColor"
/>
</svg>
)
}
function MastercardIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 24" className={className} aria-hidden="true">
<rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
<circle cx="15" cy="12" r="5" fill="currentColor" fillOpacity="0.6" />
<circle cx="23" cy="12" r="5" fill="currentColor" fillOpacity="0.4" />
</svg>
)
}
function AmexIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 24" className={className} aria-hidden="true">
<rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
<text
x="19"
y="16"
textAnchor="middle"
fontSize="8"
fontWeight="700"
fill="currentColor"
fontFamily="sans-serif"
>
AMEX
</text>
</svg>
)
}
function PayPalIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 24" className={className} aria-hidden="true">
<rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
<text
x="19"
y="16"
textAnchor="middle"
fontSize="7"
fontWeight="700"
fill="currentColor"
fontFamily="sans-serif"
>
PayPal
</text>
</svg>
)
}
function ApplePayIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 24" className={className} aria-hidden="true">
<rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
<path
d="M19 7.5C18 7.5 17.2 8 16.7 8.7C16.2 8.1 15.4 7.5 14.4 7.5C12.6 7.5 11.2 9 11.2 11C11.2 14 14.4 16.5 15.6 16.5C16.1 16.5 16.6 16.2 17 16.2C17.4 16.2 17.9 16.5 18.4 16.5C19.6 16.5 22.8 14 22.8 11C22.8 9 21.4 7.5 19 7.5Z"
fill="currentColor"
fillOpacity="0.7"
/>
</svg>
)
}
function GooglePayIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 24" className={className} aria-hidden="true">
<rect width="38" height="24" rx="4" fill="currentColor" fillOpacity="0.08" />
<text
x="19"
y="16"
textAnchor="middle"
fontSize="7"
fontWeight="700"
fill="currentColor"
fontFamily="sans-serif"
>
GPay
</text>
</svg>
)
}
// --- Lookup ---
const CARD_BRAND_ICONS: Record<string, React.ElementType> = {
visa: VisaIcon,
mastercard: MastercardIcon,
american_express: AmexIcon,
}
const PAYMENT_METHOD_ICONS: Record<string, React.ElementType> = {
paypal: PayPalIcon,
apple_pay: ApplePayIcon,
google_pay: GooglePayIcon,
}
/**
* Returns an icon component for the given Paddle payment method type and optional card brand.
* Returns `null` when no branded icon is available — callers should fall back to a generic icon.
*/
export function getPaymentMethodIcon(
type: PaddlePaymentMethodType,
cardBrand?: PaddleCardType
): React.ElementType | null {
if (type === "card" && cardBrand) {
return CARD_BRAND_ICONS[cardBrand] ?? null
}
return PAYMENT_METHOD_ICONS[type] ?? null
}