feat: implement AccountCenter dashboard with subscription and payment management features

This commit is contained in:
2569718930@qq.com
2026-05-29 21:23:12 +08:00
parent 17ddd835e6
commit 3e24080466
2 changed files with 295 additions and 270 deletions
+280 -270
View File
@@ -337,6 +337,7 @@ export function AccountCenter() {
isSubscribed && queuedExtensionDays > 0, isSubscribed && queuedExtensionDays > 0,
); );
const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription); const canAccessPaidTelegramGroup = Boolean(isSubscribed && !isTrialSubscription);
const hasTelegramPanel = Boolean(isTrialSubscription || canAccessPaidTelegramGroup);
const telegramBound = const telegramBound =
Number(backend?.telegram_pricing?.telegram_id || 0) > 0; Number(backend?.telegram_pricing?.telegram_id || 0) > 0;
const displayExpiryRaw = isSubscribed const displayExpiryRaw = isSubscribed
@@ -900,9 +901,13 @@ export function AccountCenter() {
{/* Telegram Bot Section & Payment Details */} {/* Telegram Bot Section & Payment Details */}
{showSecondarySections ? ( {showSecondarySections ? (
<div className="lg:col-span-12 grid grid-cols-1 md:flex gap-6"> <div
className={`lg:col-span-12 grid grid-cols-1 items-start gap-6 ${
hasTelegramPanel ? "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" : ""
}`}
>
{isTrialSubscription && ( {isTrialSubscription && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm"> <section className="group relative min-w-0 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 p-8 shadow-sm">
<Bot <Bot
size={140} size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100" className="absolute -right-8 -bottom-8 -rotate-12 text-amber-100"
@@ -927,7 +932,7 @@ export function AccountCenter() {
</section> </section>
)} )}
{canAccessPaidTelegramGroup && ( {canAccessPaidTelegramGroup && (
<section className="group relative flex-1 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"> <section className="group relative min-w-0 overflow-hidden rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
<Bot <Bot
size={140} size={140}
className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0" className="absolute -right-8 -bottom-8 -rotate-12 text-slate-100 transition-transform duration-1000 group-hover:rotate-0"
@@ -1005,11 +1010,7 @@ export function AccountCenter() {
)} )}
{/* Payment Details / Wallet Management */} {/* Payment Details / Wallet Management */}
<section <section className="w-full rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:p-7">
className={`flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-8 shadow-sm ${
canAccessPaidTelegramGroup || isTrialSubscription ? "w-full md:w-96" : "w-full"
}`}
>
<div> <div>
<h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700"> <h3 className="mb-6 flex items-center gap-2 text-sm font-bold uppercase text-blue-700">
<Wallet size={18} /> {copy.paymentMgmt} <Wallet size={18} /> {copy.paymentMgmt}
@@ -1043,275 +1044,282 @@ export function AccountCenter() {
)} )}
</div> </div>
) : null} ) : null}
<div className="mb-5"> <div
<p className="mb-2 text-[11px] uppercase text-slate-500"> data-testid="payment-management-grid"
{copy.proPlan} className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)] lg:items-start"
</p> >
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="space-y-5">
{displayPlanList.map((plan) => { <div>
const code = String(plan.plan_code || ""); <p className="mb-2 text-[11px] uppercase text-slate-500">
const active = code === selectedPlanCode; {copy.proPlan}
const isQuarterly = code === "pro_quarterly"; </p>
return ( <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{displayPlanList.map((plan) => {
const code = String(plan.plan_code || "");
const active = code === selectedPlanCode;
const isQuarterly = code === "pro_quarterly";
return (
<button
type="button"
key={code}
onClick={() => setSelectedPlanCode(code)}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-3 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="flex items-center justify-between gap-2 text-xs font-bold">
<span>
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan}
</span>
<span>{plan.amount_usdc} USDC</span>
</div>
<div className="mt-1 text-[10px] opacity-80">
{code} · {plan.duration_days}
</div>
</button>
);
})}
</div>
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
{copy.referralDiscountHint}
</p>
) : null}
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase text-slate-700">
{copy.referralTitle}
</p>
<span className="text-[10px] text-slate-500">
{copy.referralInviteLimit}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div>
) : (
<div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div>
)}
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div data-testid="payment-guard-grid" className="grid gap-3 sm:grid-cols-2">
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-[11px] leading-5 text-slate-500 sm:col-span-2">
{copy.paymentGuardHint}
</p>
</div>
</div>
<div className="space-y-5">
{availableChainList.length > 1 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentNetwork}
</p>
<div className="grid grid-cols-2 gap-2">
{availableChainList.map((chain) => {
const active = chain.chain_id === selectedPaymentChainId;
return (
<button
type="button"
key={chain.chain_id}
onClick={() => {
setSelectedPaymentChainId(chain.chain_id);
const nextToken =
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id &&
token.is_default,
) ||
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id,
);
if (nextToken?.address) {
setSelectedTokenAddress(
String(nextToken.address).toLowerCase(),
);
}
}}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div>
)}
{availableTokenList.length > 0 && (
<div>
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
const active =
token.address ===
(resolvedSelectedTokenAddress ||
token.address);
return (
<button
type="button"
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
</div>
<div className="truncate text-[10px] opacity-80">
{token.name}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
{copy.paymentMethodLabel}
</p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1">
<button <button
type="button" type="button"
key={code} onClick={() => setPaymentMethodTab("wallet")}
onClick={() => setSelectedPlanCode(code)} className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
disabled={paymentBusy} paymentMethodTab === "wallet"
className={`rounded-xl border px-3 py-3 text-left transition-all ${ ? "bg-white text-blue-700 shadow-sm"
active : "text-slate-500 hover:bg-white/70 hover:text-slate-900"
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`} }`}
> >
<div className="flex items-center justify-between gap-2 text-xs font-bold"> <Wallet size={12} />
<span> {copy.paymentMethodWallet}
{isQuarterly ? copy.quarterlyPlan : copy.monthlyPlan} </button>
</span> <button
<span>{plan.amount_usdc} USDC</span> type="button"
</div> onClick={() => setPaymentMethodTab("manual")}
<div className="mt-1 text-[10px] opacity-80"> className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
{code} · {plan.duration_days} paymentMethodTab === "manual"
</div> ? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button> </button>
);
})}
</div>
{appliedReferralCode && selectedPlanCode === "pro_monthly" ? (
<p className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] text-emerald-800">
{copy.referralDiscountHint}
</p>
) : null}
</div>
<div className="mb-5 rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-bold uppercase text-slate-700">
{copy.referralTitle}
</p>
<span className="text-[10px] text-slate-500">
{copy.referralInviteLimit}
</span>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralMyCode}
</p>
<div className="flex gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-slate-200 bg-white px-3 py-2 font-mono text-xs text-blue-700">
{referralCode || "--"}
</code>
{referralCode ? (
<button
type="button"
onClick={() => handleCopy(referralCode)}
className="rounded-xl border border-blue-700 bg-blue-600 px-3 text-xs font-bold text-white hover:bg-blue-700"
>
{copied ? <CheckCircle2 size={15} /> : <Copy size={15} />}
</button>
) : null}
</div> </div>
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralRewardHint}
</p>
</div>
<div>
<p className="mb-1 text-[10px] uppercase text-slate-500">
{copy.referralApplyLabel}
</p>
{appliedReferralCode ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800">
{appliedReferralCode}
</div>
) : (
<div className="flex gap-2">
<input
value={referralCodeInput}
onChange={(event) => setReferralCodeInput(event.target.value)}
placeholder={copy.referralApplyPlaceholder}
className="min-w-0 flex-1 rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs text-slate-950 outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20"
/>
<button
type="button"
onClick={() => void applyReferralCode()}
disabled={!canApplyReferralCode || referralApplying}
className="rounded-xl border border-slate-900 bg-slate-900 px-3 text-xs font-bold text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{referralApplying ? "..." : copy.referralApplyButton}
</button>
</div>
)}
<p className="mt-2 text-[11px] leading-5 text-slate-500">
{copy.referralDiscountHint}
</p>
</div>
</div>
</div>
<div className="mb-5 space-y-3">
<InfoRow
icon={Mail}
label={copy.paymentAccount}
value={email || "--"}
isPrimary
/>
<InfoRow
icon={Wallet}
label={copy.paymentWallet}
value={shortAddress(paymentWalletLabel) || "--"}
/>
<InfoRow
icon={ShieldCheck}
label={copy.paymentReceiver}
value={shortAddress(paymentReceiverAddress) || "--"}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentNetwork}
value={
selectedPaymentChain?.name ||
chainIdToDisplayName(selectedPaymentChainId)
}
/>
<InfoRow
icon={ExternalLink}
label={copy.paymentHost}
value={currentPaymentHost || "--"}
/>
<p className="text-[11px] text-slate-500">
{copy.paymentGuardHint}
</p>
</div>
{availableChainList.length > 1 && (
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentNetwork}
</p>
<div className="grid grid-cols-2 gap-2">
{availableChainList.map((chain) => {
const active = chain.chain_id === selectedPaymentChainId;
return (
<button
type="button"
key={chain.chain_id}
onClick={() => {
setSelectedPaymentChainId(chain.chain_id);
const nextToken =
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id &&
token.is_default,
) ||
paymentConfig?.tokens?.find(
(token) =>
Number(token.chain_id || chain.chain_id) ===
chain.chain_id,
);
if (nextToken?.address) {
setSelectedTokenAddress(
String(nextToken.address).toLowerCase(),
);
}
}}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{chain.name || chainIdToDisplayName(chain.chain_id)}
</div>
<div className="text-[10px] opacity-80">
{chain.native_currency_symbol || "ETH"} gas
</div>
</button>
);
})}
</div>
</div>
)}
{availableTokenList.length > 0 && (
<div className="mb-5">
<p className="mb-2 text-[11px] uppercase text-slate-500">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
const active =
token.address ===
(resolvedSelectedTokenAddress ||
token.address);
return (
<button
type="button"
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
onClick={() =>
setSelectedTokenAddress(
token.address,
)
}
disabled={paymentBusy}
className={`rounded-xl border px-3 py-2 text-left transition-all ${
active
? "border-blue-300 bg-blue-50 text-blue-900"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
<div className="text-xs font-bold">
{token.symbol}
</div>
<div className="text-[10px] opacity-80 truncate">
{token.name}
</div>
</button>
);
})}
</div>
</div>
)}
{/* Payment Method Tabs */} {paymentMethodTab === "wallet" ? (
<div className="mt-6 border-t border-slate-200 pt-6"> <div className="space-y-4">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500"> <p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentMethodLabel} {copy.paymentWalletDesc}
</p> </p>
<div className="mb-5 grid grid-cols-2 gap-2 rounded-xl border border-slate-200 bg-slate-100 p-1"> <div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
<button {copy.paymentGasWarning}
type="button" </div>
onClick={() => setPaymentMethodTab("wallet")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "wallet"
? "bg-white text-blue-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<Wallet size={12} />
{copy.paymentMethodWallet}
</button>
<button
type="button"
onClick={() => setPaymentMethodTab("manual")}
className={`py-2 rounded-lg text-xs font-bold transition-all flex items-center justify-center gap-1.5 ${
paymentMethodTab === "manual"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:bg-white/70 hover:text-slate-900"
}`}
>
<ExternalLink size={12} />
{copy.paymentMethodManual}
</button>
</div>
{paymentMethodTab === "wallet" ? (
<div className="space-y-4">
<p className="text-[11px] leading-relaxed text-slate-400">
{copy.paymentWalletDesc}
</p>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-[11px] leading-relaxed text-amber-800">
{copy.paymentGasWarning}
</div>
{boundWallets.length ? ( {boundWallets.length ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -1574,6 +1582,8 @@ export function AccountCenter() {
)} )}
</div> </div>
</div> </div>
</div>
</div>
</section> </section>
</div> </div>
) : ( ) : (
@@ -173,6 +173,21 @@ export function runTests() {
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"), accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC", "payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
); );
assert(
!accountCenterSource.includes("md:w-96"),
"account payment management must not use a narrow fixed sidebar that creates an overlong column",
);
assert(
accountCenterSource.includes("items-start") &&
accountCenterSource.includes("xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]"),
"account secondary sections must align cards to the top and give payment management a wider responsive column",
);
assert(
accountCenterSource.includes("data-testid=\"payment-management-grid\"") &&
accountCenterSource.includes("lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]") &&
accountCenterSource.includes("data-testid=\"payment-guard-grid\""),
"payment management must split plans/referrals from payment controls and compact guard details into grids",
);
assert( assert(
!appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') && !appAnalyticsSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"') &&
!analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'), !analyticsRouteSource.includes('NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"'),