Move billing and subscriptions into user panel (#685)

This commit is contained in:
shenlan 2025-11-21 21:40:55 +08:00 committed by GitHub
parent d63bef3a95
commit 9cd61b1439
5 changed files with 251 additions and 22 deletions

View File

@ -29,7 +29,6 @@ import ProductDownload from '@components/marketing/ProductDownload'
import ProductEditions from '@components/marketing/ProductEditions'
import ProductFaq from '@components/marketing/ProductFaq'
import ProductFeatures from '@components/marketing/ProductFeatures'
import ProductBillingActions from '@components/marketing/ProductBillingActions'
import ProductHero from '@components/marketing/ProductHero'
import ProductScenarios from '@components/marketing/ProductScenarios'
import type { ProductConfig } from '@modules/products/registry'
@ -185,7 +184,6 @@ export default function Client({ config }: ClientProps) {
<main>
<ProductHero config={config} lang={lang} onExportPoster={handleExportPoster} />
<ProductFeatures config={config} lang={lang} />
<ProductBillingActions config={config} lang={lang} />
<ProductEditions config={config} lang={lang} />
<ProductScenarios lang={lang} />
<ProductDownload config={config} lang={lang} />

View File

@ -0,0 +1,23 @@
'use client'
export function resolveBillingClientId(planClientId?: string) {
if (planClientId && planClientId.trim().length > 0) {
return planClientId.trim()
}
if (typeof process !== 'undefined' && typeof process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID === 'string') {
const candidate = process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID.trim()
if (candidate.length > 0) {
return candidate
}
}
if (typeof window !== 'undefined') {
const globalCandidate = (window as typeof window & { __PAYPAL_CLIENT_ID__?: string }).__PAYPAL_CLIENT_ID__
if (typeof globalCandidate === 'string' && globalCandidate.trim().length > 0) {
return globalCandidate.trim()
}
}
return ''
}

View File

@ -4,28 +4,10 @@ import { useCallback, useMemo, useState } from 'react'
import Link from 'next/link'
import { PayPalPayGoButton, PayPalSubscriptionButton } from '@components/billing/PayPalButtons'
import { resolveBillingClientId } from '@components/billing/utils'
import CryptoBillingWidget from '@components/billing/CryptoBillingWidget'
import type { BillingPaymentMethod, ProductConfig } from '@modules/products/registry'
function resolveClientId(planClientId?: string) {
if (planClientId && planClientId.trim().length > 0) {
return planClientId.trim()
}
if (typeof process !== 'undefined' && typeof process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID === 'string') {
const candidate = process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID.trim()
if (candidate.length > 0) {
return candidate
}
}
if (typeof window !== 'undefined') {
const globalCandidate = (window as typeof window & { __PAYPAL_CLIENT_ID__?: string }).__PAYPAL_CLIENT_ID__
if (typeof globalCandidate === 'string' && globalCandidate.trim().length > 0) {
return globalCandidate.trim()
}
}
return ''
}
type ProductBillingActionsProps = {
config: ProductConfig
lang: 'zh' | 'en'
@ -36,7 +18,7 @@ export default function ProductBillingActions({ config, lang }: ProductBillingAc
const billing = config.billing
const clientId = useMemo(() => {
return resolveClientId(billing?.saas?.clientId || billing?.paygo?.clientId)
return resolveBillingClientId(billing?.saas?.clientId || billing?.paygo?.clientId)
}, [billing?.paygo?.clientId, billing?.saas?.clientId])
const handleSync = useCallback(

View File

@ -0,0 +1,224 @@
'use client'
import { useCallback, 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'
import type { BillingPaymentMethod, BillingPlan, ProductConfig } from '@modules/products/registry'
import { PRODUCT_LIST } from '@modules/products/registry'
type SyncPayload = {
externalId: string
kind: string
planId?: string
status: string
provider?: string
paymentMethod?: string
paymentQr?: string
meta?: Record<string, unknown>
}
type BillingOptionProps = {
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>
<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>
{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>
)
}
export default function BillingOptionsPanel() {
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const products = useMemo(
() => PRODUCT_LIST.filter((item) => item.billing && (item.billing.paygo || item.billing.saas)),
[],
)
const handleSync = useCallback(async (payload: SyncPayload) => {
try {
setStatusMessage(null)
const response = await fetch('/api/auth/subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider: payload.provider || 'paypal',
paymentMethod: payload.paymentMethod || payload.provider || 'paypal',
paymentQr: payload.paymentQr,
...payload,
}),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
setStatusMessage((data?.message as string) || '同步支付信息失败,请稍后重试。')
return
}
setStatusMessage('支付记录已同步到账户。')
} catch (error) {
console.warn('Failed to sync subscription', error)
setStatusMessage('同步支付记录时出错。')
}
}, [])
if (!products.length) {
return null
}
return (
<Card>
<div className="flex flex-col gap-2">
<div>
<p className="text-sm font-semibold text-[var(--color-primary)]"></p>
<h2 className="text-xl font-semibold text-[var(--color-heading)]"></h2>
<p className="text-sm text-[var(--color-text-subtle)]">
PayPal USDT
</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>
)
})}
</div>
</Card>
)
}

View File

@ -1,4 +1,5 @@
import Card from '../components/Card'
import BillingOptionsPanel from '../account/BillingOptionsPanel'
import SubscriptionPanel from '../account/SubscriptionPanel'
export default function UserCenterSubscriptionRoute() {
@ -10,6 +11,7 @@ export default function UserCenterSubscriptionRoute() {
PayPal USDT
</p>
</Card>
<BillingOptionsPanel />
<SubscriptionPanel />
</div>
)