mcpField
P
paddle-pricing-pages

paddle-pricing-pages

github.com/paddlehq开发9109 次安装6 天前更新
代码仓库

关于 paddle-pricing-pages

此技能指导在 Next.js 中使用 Paddle.js 构建本地化定价页和升级界面,涵盖地区货币与税费预览、国家选择、计费周期切换,以及让展示价格与结账金额保持一致。

主要功能

  1. 01预览本地化价格和税费
  2. 02检测或选择国家
  3. 03添加月付和年付切换
  4. 04处理零小数位货币
  5. 05让展示价格与结账一致

适用场景

  1. 01构建公开定价页面
  2. 02显示本地化应用内升级价格
  3. 03核对不同国家的价格

MCP 相关

如果有 Paddle MCP 服务,可调用其价格预览工具在服务端获取相同价格数据,并核对不同国家客户看到的价格。

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-pricing-pages

安装后在 Claude Code 中输入/paddle-pricing-pages确认已加载。

详细介绍

在 Next.js 中显示本地化价格

何时使用此技能

在构建公开定价页面(或应用内升级界面),且需要以用户的本地货币显示价格并正确处理税费时,请使用此技能。内容涵盖 Paddle.PricePreview() API、国家/地区检测、计费周期切换(月付/年付)、零小数货币的格式化注意事项(JPY/KRW/VND/CLP),以及如何确保显示的价格与结账时的收费金额一致。

这是客户端相关的事项。关于结账本身,请参阅 checkout-web——此处显示的价格 ID 与传入 Paddle.Checkout.open() 的相同。

为什么要使用 PricePreview?

你_可以_在 UI 中硬编码“$10/月”,但 Paddle 内置了强大的区域定价功能——同一套餐在美国可能是 $10,在欧盟是 €9(含 VAT),在英国是 £8,在日本是 ¥1,200。对于使用同一种货币的多个国家/地区,你可以为每个国家/地区设置不同的价格,以适应购买力平价。硬编码只适用于单一市场。

PricePreview() 会根据你提供的位置(如果未提供,则根据访客的 IP 地址)返回价格,并计算好货币、格式和适用税费,无需在客户端进行计算。

如果你可以使用 Paddle MCP 服务,请在 execute 中调用 client.pricingPreview.preview({ items: [{ price_id: "pri_...", quantity: 1 }], address: { country_code: "US" } }),以便在服务端获取相同的数据——这有助于在接入客户端钩子之前,验证不同国家/地区的用户会看到什么价格。注意,pricingPreview 使用驼峰命名法,而 price_id 和 country_code 使用蛇形命名法。不要传入 currency_code:它会固定货币,而不是让 Paddle 选择本地货币。

每个 Paddle MCP 服务会提供三个工具(search、execute、report_missing_tool)。工作流:调用 search 确认准确的方法名称和参数结构,然后调用 execute,并传入一个调用 client.<resource>.<operation>(...) 的异步函数。方法路径使用驼峰命名法(client.clientTokens.create、client.pricingPreview.preview)。请求体参数和响应字段使用蛇形命名法(tax_category、product_id、unit_price、currency_code)。列表方法返回 { pagination: { hasMore, estimatedTotal }, <resource>: [...] },而不是 data。数组的键是资源的 API 路径片段,因此方法路径使用驼峰命名法时,键使用烤串命名法:(await client.products.list()).products、(await client.clientTokens.list())["client-tokens"]。当 pagination.hasMore 为 true 时,使用 { after: "<last_id>" } 翻页,不要使用 .next()。未分页的列表(例如 client.currencies.list())会返回普通数组。单实体方法会返回该实体。在一次 execute 中串联多步工作流;变量不会在调用之间保留。硬性限制:每次 execute 最多 50 次 API 调用、超时时间为 30s,代码最多 32,000 个字符。

前置条件

  • Paddle.js 的配置与 checkout-web 相同:
npm install @paddle/paddle-js
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_...
NEXT_PUBLIC_PADDLE_ENV=sandbox
  • Paddle 目录中有一个或多个价格,最好为你销售的市场设置价格覆盖。
    • 只有在 Paddle > My account > Settings > Currencies 中启用的货币,才会自动将单一基础价格(无覆盖)转换为对应货币。否则,未设置覆盖的国家/地区的客户会看到基础货币。
    • 明确设置价格覆盖,可让你控制价格取整和心理定价(例如 €9,而不是 €9.13)。
    • 如果还没有价格,请使用 catalog-setup——该技能包含区域价格覆盖相关章节。

定义套餐等级

典型的设置方式是将套餐等级元数据(名称、功能)放在常量文件中,并在旁边列出 Paddle 价格 ID:

// constants/pricing-tier.ts
export interface Tier {
  name: string;
  id: "starter" | "pro" | "advanced";
  description: string;
  features: string[];
  featured: boolean;
  priceId: { month: string; year: string };
}

export const PricingTier: Tier[] = [
  {
    name: "Starter",
    id: "starter",
    description: "Get going.",
    features: ["1 workspace", "Limited collaboration"],
    featured: false,
    priceId: {
      month: "pri_01h...",
      year: "pri_02h...",
    },
  },
  // ...
];

The PricePreview hook

一个小型自定义 hook 将定价逻辑集中在一处。它接收 Paddle 实例和国家代码,返回一个 priceId → formatted total 映射,以及加载和错误状态:

// hooks/usePaddlePrices.ts
import { type Paddle, type PricePreviewParams, type PricePreviewResponse } from "@paddle/paddle-js";
import { useEffect, useState } from "react";
import { PricingTier } from "@/constants/pricing-tier";

export type PaddlePrices = Record<string, string>;

function getLineItems(): PricePreviewParams["items"] {
  return PricingTier.flatMap((tier) =>
    [tier.priceId.month, tier.priceId.year].map((priceId) => ({
      priceId,
      quantity: 1,
    })),
  );
}

function getPriceAmounts(prices: PricePreviewResponse): PaddlePrices {
  return prices.data.details.lineItems.reduce<PaddlePrices>((acc, item) => {
    acc[item.price.id] = item.formattedTotals.total;
    return acc;
  }, {});
}

export function usePaddlePrices(
  paddle: Paddle | undefined,
  country: string,
): { prices: PaddlePrices; loading: boolean; error: string | null } {
  const [prices, setPrices] = useState<PaddlePrices>({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!paddle) return;

    const params: PricePreviewParams = {
      items: getLineItems(), // up to 100 items per call
      // 'OTHERS' is a sentinel meaning "let Paddle infer from IP".
      ...(country !== "OTHERS" && { address: { countryCode: country } }),
    };

    setLoading(true);
    setError(null);
    paddle
      .PricePreview(params)
      .then((response) => {
        setPrices((prev) => ({ ...prev, ...getPriceAmounts(response) }));
      })
      .catch((err: unknown) => {
        // API errors reject with the API's error body: { error: { type, code, detail, ... } }.
        console.error("Paddle.PricePreview failed", err);
        setError("We couldn't load prices. Please refresh the page.");
      })
      .finally(() => setLoading(false));
  }, [country, paddle]);

  return { prices, loading, error };
}

item.formattedTotals.total 是完整格式化后的字符串,例如 "$9.99" 或 "¥1,200",其中包含货币符号和符合地区习惯的分组格式。请直接使用,不要重新格式化。它反映了你传入的位置(或访客的 IP),因此只有在结账使用相同位置时,价格才会与结账一致——请参阅下文“与结账结合使用”。

如果需要原始金额(用于计算、比较或自定义显示),item.totals.total 会返回以货币最小单位表示的字符串($9.99 对应 "999";不使用小数位的货币则为整单位——请参阅下文“货币单位格式”)。

如果价格包含试用期(item.price.trialPeriod 不为 null),客户今天支付的金额可能与定期价格不同,因为试用期可以免费,也可以收费。请在卡片上明确说明试用条款。

定价页面组件

将 hook 与 Paddle.js 初始化和计费频率切换开关组合使用:

// components/pricing.tsx
"use client";

import { type Environments, initializePaddle, type Paddle } from "@paddle/paddle-js";
import { useEffect, useState } from "react";
import { usePaddlePrices } from "@/hooks/usePaddlePrices";
import { PricingTier } from "@/constants/pricing-tier";

interface Props {
  country: string;
}

export function Pricing({ country }: Props) {
  const [frequency, setFrequency] = useState<"month" | "year">("month");
  const [paddle, setPaddle] = useState<Paddle | undefined>();

  const { prices, loading, error } = usePaddlePrices(paddle, country);

  useEffect(() => {
    if (!process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN) return;
    initializePaddle({
      token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
      environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments,
    }).then((p) => p && setPaddle(p));
  }, []);

  return (
    <div>
      <FrequencyToggle value={frequency} onChange={setFrequency} />
      {error && <p role="alert">{error}</p>}
      <div className="grid grid-cols-3 gap-6">
        {PricingTier.map((tier) => {
          const priceId = tier.priceId[frequency];
          const formatted = prices[priceId];
          return (
            <div key={tier.id}>
              <h3>{tier.name}</h3>
              <p className="text-3xl">
                {loading || !formatted ? "..." : formatted}
                <span className="text-sm">/{frequency}</span>
              </p>
              {/* ... features, CTA button ... */}
            </div>
          );
        })}
      </div>
    </div>
  );
}

此模式初始化 Paddle.js 时不包含结账配置——你只需要客户端令牌来调用 PricePreview()。用户点击“订阅”时,直接将结账设置传递给 Paddle.Checkout.open({ settings })(请参阅下文“与结账结合使用”),而不是再次调用 initializePaddle。

国家检测

以下有三种选项,准确度和成本依次递增:

A. 默认使用合适的市场。 对于美国访客、禁用 JS 的访客和受地理位置限制的访客,只需默认使用 OTHERS(Paddle 会在结账时根据 IP 推断位置):

<Pricing country="OTHERS" />

B. 从请求标头读取。 在 App Router 中,Server Component 里的 headers() 会提供请求标头,其中包含 CDN 提供的地理位置信息(例如,Vercel 会设置 x-vercel-ip-country):

// app/pricing/page.tsx
import { headers } from "next/headers";
import { Pricing } from "@/components/pricing";

export default async function PricingPage() {
  const h = await headers();
  const country = h.get("x-vercel-ip-country") ?? "OTHERS";
  return <Pricing country={country} />;
}

C. 让用户选择。 使用一个包含受支持国家/地区列表的 <select>,默认选中根据 IP 检测到的国家/地区。这适用于向全球受众展示价格并允许他们切换(例如,美国买家为德国同事查询价格)。不推荐这样做,因为用户可能会选择价格最低的国家/地区。

国家/地区选择器

const COUNTRIES = [
  { code: "OTHERS", label: "Default (auto)" },
  { code: "US", label: "United States" },
  { code: "GB", label: "United Kingdom" },
  { code: "DE", label: "Germany" },
  { code: "JP", label: "Japan" },
  // ...
];

export function CountrySelect({
  value,
  onChange,
}: {
  value: string;
  onChange: (v: string) => void;
}) {
  return (
    <select value={value} onChange={(e) => onChange(e.target.value)}>
      {COUNTRIES.map((c) => (
        <option key={c.code} value={c.code}>
          {c.label}
        </option>
      ))}
    </select>
  );
}

国家/地区变更时,usePaddlePrices hook 会重新获取数据——无需额外配置。

货币单位格式

货币以其最小单位存储:1099 表示 $10.99,850 表示 £8.50,2500 表示 €25.00。

有四种货币不使用小数位:JPY、KRW、VND 和 CLP。1200 表示 ¥1,200 / ₩1,200 / ₫1,200 / CLP$1,200(而不是 ¥12.00 等),因为 Paddle 中这些货币没有辅币单位。

当你取得原始的 item.totals.total 字符串并将其转换为数字,以用于自己的 UI 时,这一点很重要:

// utils/parse-money.ts
export function convertAmountFromLowestUnit(amount: string, currency: string): number {
  switch (currency) {
    case "JPY":
    case "KRW":
    case "VND":
    case "CLP":
      return parseFloat(amount);
    default:
      return parseFloat(amount) / 100;
  }
}

export function formatMoney(amount: number, currency: string): string {
  const language = typeof navigator !== "undefined" ? navigator.language : "en-US";
  return new Intl.NumberFormat(language, {
    style: "currency",
    currency,
  }).format(amount);
}

export function parseMoney(amount = "0", currency = "USD"): string {
  return formatMoney(convertAmountFromLowestUnit(amount, currency), currency);
}

为避免硬编码列表,请从 GET https://api.paddle.com/currencies(沙盒环境:https://sandbox-api.paddle.com/currencies)读取每种货币的 decimal_places。此接口公开可用,无需 API 密钥;Node SDK 未对此提供封装,因此请在服务器上使用 fetch 调用并缓存结果。通过 Paddle MCP,client.currencies.list() 会以普通数组的形式返回相同列表。

// utils/currency-decimals.ts (server-side)
export async function getCurrencyDecimals(): Promise<Record<string, number>> {
  const res = await fetch("https://api.paddle.com/currencies", { next: { revalidate: 86400 } });
  if (!res.ok) throw new Error(`Failed to load currencies: ${res.status}`);
  const { data } = (await res.json()) as { data: { code: string; decimal_places: number }[] };
  return Object.fromEntries(data.map((c) => [c.code, c.decimal_places]));
}

// parseFloat(amount) / 10 ** decimals[currency]

如果你使用 API 返回的 formattedTotals.total,则无需处理这些——Paddle 会自动完成。只有在计算自己的金额时(例如显示月付与年付之间“节省 20%”)才需要解析辅助函数。

可以考虑直接使用 Intl.NumberFormat 格式化金额,而不是使用解析辅助函数。请参阅:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat

与结账结合

用户点击某个套餐上的“订阅”时,传入 PricePreview 中使用的同一个 priceId,并将任何结账设置直接传给 Checkout.open():

function handleSubscribe(tier: Tier, frequency: "month" | "year") {
  paddle?.Checkout.open({
    items: [{ priceId: tier.priceId[frequency], quantity: 1 }],
    settings: {
      variant: "one-page",
      successUrl: `${window.location.origin}/checkout/success`,
    },
  });
}

Checkout 会根据客户的 IP 地址自行检测其国家/地区,不会复用页面传给 PricePreview 的国家/地区。通常两者一致,但如果页面使用了不同的国家/地区(例如通过国家/地区选择器指定),结账可能会显示不同的货币或税费。要让两者保持一致,请预填国家/地区:

paddle?.Checkout.open({
  items: [{ priceId: tier.priceId[frequency], quantity: 1 }],
  customer: { email: user.email, address: { countryCode: country } },
});

预填国家/地区时,customer 中必须包含 email 或 id。对于使用邮政编码的国家/地区(例如 GB、DE、CA),还需传入 postalCode,否则国家/地区信息会被丢弃,结账会触发 checkout.warning。

常见问题

  • 重新格式化 formattedTotals.total。 它已经是按地区格式化的字符串。再用 Intl.NumberFormat 包装会产生错误结果("$$9.99"),或直接抛出异常。
  • 忘记零小数位货币的特殊情况。 将 JPY/KRW/VND/CLP 除以 100,会使原始值变成小 100 倍的数字。请始终按货币进行分支处理,或使用 /currencies 中的 decimal_places。
  • 没有为 PricePreview 添加 .catch。 它会返回一个失败时拒绝的 Promise(价格 ID 错误、环境不匹配或网络问题)。如果没有处理程序,loading 就不会清除,卡片会永远显示 ...。
  • 自行计算原始金额。 不推荐这样做。Paddle 会自动为你处理计算,包括全球税务合规。
  • 在 API 调用中将 address.countryCode 设为 'OTHERS'。 'OTHERS' 是你在代码中用来表示“不要传入地址”的哨兵值,Paddle 无法识别。请改为移除 address 字段(如上面的 Hook 所示)。
  • 在 Paddle.js 初始化前显示价格。 页面加载后约 200ms 内,prices[priceId] 会是 undefined。请明确处理加载状态(显示 '...' 占位符或骨架屏)。
  • 每次渲染都调用 PricePreview。 上面的 Hook 会在 country 变化时重新获取数据。如果你还在 frequency 变化时重新获取,就会重复请求;将 priceId.month 和 priceId.year 都加入 items 后,一次调用就会返回两种价格。一次调用最多可处理 100 个条目;目录更大时请拆分。
  • 混用沙盒和生产环境的价格 ID。 沙盒中的 pri_01h... 在生产环境中不存在,因此 PricePreview Promise 会被拒绝。
  • 未创建价格覆盖项。 如果没有覆盖项,Paddle 只会自动转换为你在 Paddle > My account > Settings > Currencies 中启用的货币;其他用户会看到你的基础货币。自动转换比向所有用户都显示 USD 价格更有利于买家体验,但我们建议为主要市场(US、EU、UK、JP 等)设置明确的覆盖项。

验证集成

  1. 不传入国家/地区属性,打开定价页面——确认价格在约 500ms 内显示。
  2. 打开开发者工具 → Network → 筛选 paddle.com。你应看到一个 PricePreview 请求,返回的 JSON 负载中包含每个价格对应的 formattedTotals。
  3. 将国家/地区选择器切换为 JP、KR、VN 或 CL。如果你在这些货币中设置了覆盖项,或在 Paddle > My account > Settings > Currencies 中启用了这些货币,请确认价格以 JPY/KRW/VND/CLP 显示,且没有小数位。否则,价格会继续使用基础货币。
  4. 切换到 DE——确认价格包含 VAT(与不包含 VAT 的 US 对比)。
  5. 点击某个套餐上的“订阅”——确认结账以相同的货币和金额打开。如果不一致,说明结账检测到的国家/地区与预览时不同:请预填该国家/地区(参见“与结账结合”)。
  6. 将 NEXT_PUBLIC_PADDLE_ENV=production 与生产环境令牌配合使用时,确认生产环境价格 ID 能正常解析,沙盒 ID 会显示错误状态。

相关文档

评论

支持 Markdown