Refine subscription payment layout (#686)

This commit is contained in:
shenlan 2025-11-21 21:54:25 +08:00 committed by GitHub
parent 065f77c8ac
commit 152ebe374c
2 changed files with 292 additions and 143 deletions

View File

@ -1,8 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import CryptoBillingWidget from '@components/billing/CryptoBillingWidget'
import { PayPalPayGoButton, PayPalSubscriptionButton } from '@components/billing/PayPalButtons'
import { resolveBillingClientId } from '@components/billing/utils'
import Card from '../components/Card'
@ -20,123 +19,64 @@ type SyncPayload = {
meta?: Record<string, unknown>
}
type BillingOptionProps = {
type ProductOption = {
product: ProductConfig
plan: BillingPlan
kind: 'paygo' | 'subscription'
clientId: string
product: ProductConfig
onSync: (payload: SyncPayload) => Promise<void>
}
function BillingOption({ plan, kind, clientId, product, onSync }: BillingOptionProps) {
return (
<div className="rounded-xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">
{kind === 'paygo' ? 'Pay-as-you-go' : 'SaaS'}
</p>
<h3 className="text-lg font-semibold text-[var(--color-heading)]">{plan.name}</h3>
{plan.description ? (
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">{plan.description}</p>
) : null}
<p className="mt-2 text-lg font-bold text-[var(--color-heading)]">
{plan.currency} {plan.price.toFixed(2)}
{kind === 'subscription' && plan.interval ? ` / ${plan.interval}` : null}
</p>
</div>
<div className="text-right text-xs text-[var(--color-text-subtle)]">
{clientId ? 'PayPal / ETH / USDT 结算' : '尚未配置 PayPal Client ID'}
</div>
</div>
const kindLabel: Record<'paygo' | 'subscription', string> = {
paygo: 'PAY-AS-YOU-GO',
subscription: 'SAAS',
}
<div className="mt-4 space-y-3">
<div className="rounded-lg border border-[color:var(--color-surface-border)] bg-white p-3">
{kind === 'paygo' ? (
<PayPalPayGoButton
clientId={clientId}
currency={plan.currency}
amount={plan.price}
description={plan.description}
productSlug={product.slug}
planId={plan.planId}
onApprove={(orderId, data) =>
onSync({
externalId: orderId,
kind,
planId: plan.planId,
status: 'active',
provider: 'paypal',
paymentMethod: 'paypal',
meta: { ...plan.meta, product: product.slug, paypal: data },
})
}
/>
) : (
<PayPalSubscriptionButton
clientId={clientId}
currency={plan.currency}
planId={plan.planId}
productSlug={product.slug}
onApprove={(subscriptionId, data) =>
onSync({
externalId: subscriptionId,
kind,
planId: plan.planId,
status: 'active',
provider: 'paypal',
paymentMethod: 'paypal',
meta: { ...plan.meta, product: product.slug, paypal: data },
})
}
/>
)}
</div>
const actionLabel: Record<'paygo' | 'subscription', string> = {
paygo: '立即购买',
subscription: '立即订阅',
}
{plan.paymentMethods?.length ? (
<div className="space-y-2">
<p className="text-sm font-medium text-[var(--color-heading)]">
{kind === 'paygo' ? '扫码付款' : '扫码订阅'}PayPal / / USDT
</p>
<div className="grid gap-3 md:grid-cols-2">
{plan.paymentMethods.map((method: BillingPaymentMethod) => (
<CryptoBillingWidget
key={`${plan.planId}-${method.type}`}
method={method}
planId={plan.planId}
planName={plan.name}
kind={kind}
productSlug={product.slug}
onRecord={(details) =>
onSync({
externalId: details.externalId,
kind,
planId: plan.planId,
status: details.status || 'pending',
provider: method.type,
paymentMethod: method.type,
paymentQr: details.paymentQr,
meta: { ...plan.meta, ...details.meta, product: product.slug },
})
}
/>
))}
</div>
</div>
) : null}
</div>
</div>
)
const methodHints: Record<BillingPaymentMethod['type'], string> = {
paypal: '建议使用 PayPal App 扫码,支付成功后系统自动确认订单。',
ethereum: '使用以太坊ERC20转账支付后订单会自动识别并激活。',
usdt: '使用 USDTTRC20转账支付完成后自动续订或开通。',
}
export default function BillingOptionsPanel() {
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const [selected, setSelected] = useState<ProductOption | null>(null)
const products = useMemo(
() => PRODUCT_LIST.filter((item) => item.billing && (item.billing.paygo || item.billing.saas)),
[],
)
const productOptions = useMemo(() => {
const options: ProductOption[] = []
products.forEach((product) => {
const paygo = product.billing?.paygo
const saas = product.billing?.saas
const clientId = resolveBillingClientId(saas?.clientId || paygo?.clientId)
if (paygo) {
options.push({ product, plan: paygo, kind: 'paygo', clientId })
}
if (saas) {
options.push({ product, plan: saas, kind: 'subscription', clientId })
}
})
return options
}, [products])
useEffect(() => {
if (!selected && productOptions.length) {
setSelected(productOptions[0])
}
}, [productOptions, selected])
const handleSync = useCallback(async (payload: SyncPayload) => {
try {
setStatusMessage(null)
@ -170,54 +110,260 @@ export default function BillingOptionsPanel() {
return null
}
const activeMethods = useMemo(() => {
if (!selected?.plan.paymentMethods) return []
const order: BillingPaymentMethod['type'][] = ['paypal', 'ethereum', 'usdt']
return order
.map((type) => selected.plan.paymentMethods?.find((method) => method.type === type))
.filter(Boolean) as BillingPaymentMethod[]
}, [selected?.plan.paymentMethods])
const handleCryptoRecord = useCallback(
(method: BillingPaymentMethod) => {
if (!selected) return
const externalId = `${method.type}-${selected.plan.planId || selected.kind}-${Date.now()}`
handleSync({
externalId,
kind: selected.kind,
planId: selected.plan.planId,
status: 'pending',
provider: method.type,
paymentMethod: method.type,
paymentQr: method.qrCode,
meta: {
...selected.plan.meta,
product: selected.product.slug,
paymentMethod: method.type,
address: method.address,
network: method.network,
instructions: method.instructions,
},
})
},
[handleSync, selected],
)
const renderCryptoCard = (method: BillingPaymentMethod) => {
const address = method.address?.trim()
const network = method.network?.trim()
const qrCode = method.qrCode?.trim()
return (
<div
key={`${selected?.plan.planId}-${method.type}`}
className="flex flex-col gap-4 rounded-2xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm"
>
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">{method.label || method.type}</p>
<p className="text-sm font-semibold text-[var(--color-heading)]">{method.type === 'ethereum' ? 'ETH 扫码支付' : 'USDTTRC20扫码'}</p>
<p className="text-xs text-[var(--color-text-subtle)]">{methodHints[method.type]}</p>
</div>
{qrCode ? (
<div className="rounded-xl bg-white p-3 text-center">
<img src={qrCode} alt={`${method.label || method.type} QR`} className="mx-auto h-44 w-44 object-contain" />
</div>
) : null}
{address ? (
<div className="space-y-1 rounded-xl bg-white p-3 text-xs text-[var(--color-text)]">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col">
<span className="font-semibold text-[var(--color-heading)]"></span>
<span className="truncate font-mono" title={address}>
{address}
</span>
{network ? <span className="text-[11px] text-[var(--color-text-subtle)]"> / Network{network}</span> : null}
</div>
<button
type="button"
onClick={async () => {
if (!navigator.clipboard?.writeText) return
await navigator.clipboard.writeText(address)
setStatusMessage('已复制钱包地址,完成支付后订单将自动识别。')
}}
className="inline-flex items-center rounded-md bg-[var(--color-heading)] px-3 py-1 text-[11px] font-semibold text-white shadow-sm transition-colors hover:bg-[var(--color-primary)]"
>
</button>
</div>
</div>
) : null}
<div className="mt-auto flex flex-wrap gap-2">
<button
type="button"
onClick={() => handleCryptoRecord(method)}
className="inline-flex w-full items-center justify-center rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--color-primary-strong)]"
>
使
</button>
</div>
</div>
)
}
const renderPayPalCard = (method: BillingPaymentMethod) => {
if (!selected) return null
return (
<div className="flex flex-col gap-4 rounded-2xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface)] p-4 shadow-sm">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">PayPal</p>
<p className="text-sm font-semibold text-[var(--color-heading)]">PayPal </p>
<p className="text-xs text-[var(--color-text-subtle)]">{methodHints.paypal}</p>
</div>
{method.qrCode ? (
<div className="rounded-xl bg-white p-3 text-center">
<img src={method.qrCode} alt="PayPal QR" className="mx-auto h-44 w-44 object-contain" />
</div>
) : null}
<div className="mt-auto space-y-2">
<div className="rounded-lg border border-[color:var(--color-surface-border)] bg-white p-3 text-center">
{selected.kind === 'paygo' ? (
<PayPalPayGoButton
clientId={selected.clientId}
currency={selected.plan.currency}
amount={selected.plan.price}
description={selected.plan.description}
productSlug={selected.product.slug}
planId={selected.plan.planId}
onApprove={(orderId, data) =>
handleSync({
externalId: orderId,
kind: selected.kind,
planId: selected.plan.planId,
status: 'active',
provider: 'paypal',
paymentMethod: 'paypal',
meta: { ...selected.plan.meta, product: selected.product.slug, paypal: data },
})
}
/>
) : (
<PayPalSubscriptionButton
clientId={selected.clientId}
currency={selected.plan.currency}
planId={selected.plan.planId}
productSlug={selected.product.slug}
onApprove={(subscriptionId, data) =>
handleSync({
externalId: subscriptionId,
kind: selected.kind,
planId: selected.plan.planId,
status: 'active',
provider: 'paypal',
paymentMethod: 'paypal',
meta: { ...selected.plan.meta, product: selected.product.slug, paypal: data },
})
}
/>
)}
</div>
<button
type="button"
className="inline-flex w-full items-center justify-center rounded-md bg-[var(--color-primary)] px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[var(--color-primary-strong)]"
onClick={() => setStatusMessage('正在使用 PayPal 支付,完成后系统会自动同步订单。')}
>
使
</button>
</div>
</div>
)
}
return (
<Card>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
<div>
<p className="text-sm font-semibold text-[var(--color-primary)]"></p>
<h2 className="text-xl font-semibold text-[var(--color-heading)]"></h2>
<h2 className="text-xl font-semibold text-[var(--color-heading)]"></h2>
<p className="text-sm text-[var(--color-text-subtle)]">
PayPal USDT
PayPal / ETH / USDTTRC20
</p>
</div>
{statusMessage ? <p className="text-sm text-[var(--color-primary)]">{statusMessage}</p> : null}
</div>
<div className="mt-4 space-y-6">
{products.map((product) => {
const paygo = product.billing?.paygo
const saas = product.billing?.saas
const clientId = resolveBillingClientId(saas?.clientId || paygo?.clientId)
return (
<div key={product.slug} className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--color-primary)]">{product.name}</p>
<h3 className="text-lg font-semibold text-[var(--color-heading)]">{product.title}</h3>
<p className="text-sm text-[var(--color-text-subtle)]">{product.tagline_zh}</p>
</div>
<p className="text-xs text-[var(--color-text-subtle)]">
{clientId ? '已启用 PayPal 结算' : '尚未配置 PayPal Client ID'}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
{paygo ? (
<BillingOption plan={paygo} kind="paygo" clientId={clientId} product={product} onSync={handleSync} />
) : null}
{saas ? (
<BillingOption
plan={saas}
kind="subscription"
clientId={clientId}
product={product}
onSync={handleSync}
/>
) : null}
</div>
<div className="mt-2 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]"></p>
<h3 className="text-lg font-semibold text-[var(--color-heading)]"></h3>
<p className="text-sm text-[var(--color-text-subtle)]">PAYG SaaS </p>
</div>
)
})}
{selected?.clientId ? (
<p className="text-xs text-[var(--color-text-subtle)]"> PayPal / </p>
) : (
<p className="text-xs text-[var(--color-text-subtle)]"> PayPal Client ID</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
{productOptions.map((option) => {
const isActive = selected?.plan.planId === option.plan.planId && selected.kind === option.kind
return (
<div
key={`${option.product.slug}-${option.kind}`}
className={`flex h-full flex-col rounded-2xl border p-4 shadow-sm transition-colors ${
isActive
? 'border-[color:var(--color-primary)] bg-[color:var(--color-surface)]'
: 'border-[color:var(--color-surface-border)] bg-[color:var(--color-surface-muted)] hover:border-[color:var(--color-primary)]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]">
{kindLabel[option.kind]}
</p>
<h4 className="text-lg font-semibold text-[var(--color-heading)]">{option.plan.name}</h4>
{option.plan.description ? (
<p className="text-sm text-[var(--color-text-subtle)]">{option.plan.description}</p>
) : null}
<p className="text-xl font-bold text-[var(--color-heading)]">
{option.plan.currency} {option.plan.price.toFixed(2)}
{option.kind === 'subscription' && option.plan.interval ? ` / ${option.plan.interval}` : null}
</p>
</div>
</div>
<div className="mt-auto flex flex-wrap gap-2">
<button
type="button"
onClick={() => setSelected(option)}
className={`inline-flex w-full items-center justify-center rounded-md px-4 py-2 text-sm font-semibold shadow-sm transition-colors ${
isActive
? 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-strong)]'
: 'bg-white text-[var(--color-heading)] hover:bg-[var(--color-surface)]'
}`}
>
{actionLabel[option.kind]}
</button>
</div>
</div>
)
})}
</div>
</div>
{selected ? (
<div className="space-y-3 rounded-2xl border border-[color:var(--color-surface-border)] bg-[color:var(--color-surface-muted)] p-4">
<div className="flex flex-col gap-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--color-primary)]"></p>
<h3 className="text-lg font-semibold text-[var(--color-heading)]"></h3>
<p className="text-sm text-[var(--color-text-subtle)]">
PayPal ETH/USDT
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{activeMethods.map((method) =>
method.type === 'paypal' ? renderPayPalCard(method) : renderCryptoCard(method),
)}
</div>
</div>
) : null}
{statusMessage ? <p className="text-sm text-[var(--color-primary)]">{statusMessage}</p> : null}
</div>
</Card>
)

View File

@ -6,9 +6,12 @@ export default function UserCenterSubscriptionRoute() {
return (
<div className="space-y-4">
<Card>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<h1 className="text-2xl font-semibold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-600">
PayPal USDT
PayPal / ETH / USDTTRC20
</p>
<p className="mt-1 text-sm text-gray-600">
PAYG / SaaS
</p>
</Card>
<BillingOptionsPanel />