feat: Implement user account center with authentication, subscription management, and cryptocurrency payment integration.

This commit is contained in:
2569718930@qq.com
2026-03-16 20:30:46 +08:00
parent 6a229a592b
commit 609fc8914a
6 changed files with 448 additions and 80 deletions
@@ -40,3 +40,55 @@ export async function GET(req: NextRequest) {
}
}
export async function DELETE(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
let payload: Record<string, unknown> = {};
try {
payload = (await req.json()) as Record<string, unknown>;
} catch {
payload = {};
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
method: "DELETE",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok) {
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
let data: unknown = { ok: true };
if (raw) {
try {
data = JSON.parse(raw);
} catch {
data = { ok: true, raw };
}
}
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to unbind wallet", detail: String(error) },
{ status: 500 },
);
}
}
+6 -2
View File
@@ -1,4 +1,5 @@
import { LoginClient } from "@/components/auth/LoginClient";
import { I18nProvider } from "@/hooks/useI18n";
type PageProps = {
searchParams?: Promise<{ next?: string }>;
@@ -16,6 +17,9 @@ function normalizeNextPath(input: string | undefined) {
export default async function LoginPage({ searchParams }: PageProps) {
const params = (await searchParams) || {};
const nextPath = normalizeNextPath(params.next);
return <LoginClient nextPath={nextPath} />;
return (
<I18nProvider>
<LoginClient nextPath={nextPath} />
</I18nProvider>
);
}
+234 -58
View File
@@ -41,6 +41,7 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { useI18n } from "@/hooks/useI18n";
const UnlockProOverlay = dynamic(
() =>
@@ -489,6 +490,94 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
export function AccountCenter() {
const router = useRouter();
const { locale } = useI18n();
const isEn = locale === "en-US";
const copy = useMemo(
() => ({
backHome: isEn ? "Back to Home" : "返回首页",
accountCenter: isEn ? "Account Center" : "账户中心",
loadingAccount: isEn ? "Loading account info..." : "加载账户信息中...",
refresh: isEn ? "Refresh" : "刷新",
signOut: isEn ? "Sign Out" : "退出",
signIn: isEn ? "Sign In" : "登录",
upgradePro: isEn ? "Upgrade Pro" : "升级 Pro",
guestUser: isEn ? "Guest User" : "游客用户",
joinedAt: isEn ? "Joined" : "加入时间",
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
identityStatus: isEn ? "Identity Status" : "身份状态",
authMode: isEn ? "Auth Mode" : "鉴权模式",
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
historyFuture:
isEn ? "Historical + Future-date Analysis" : "历史对账 + 未来日期分析",
smartPush:
isEn ? "Cross-platform Smart Weather Push" : "全平台智能气象推送",
deepMode:
isEn ? "Deep mode (incl. high-temp window)" : "深度版(含高温时段)",
compactVisible: isEn ? "Compact visible" : "简版可见",
enabled: isEn ? "Enabled" : "已开启",
locked: isEn ? "Locked" : "锁定",
boundEmail: isEn ? "Bound Email" : "绑定邮箱",
loginMethod: isEn ? "Sign-in Method" : "登录方式",
renewalDate: isEn ? "Renewal Date" : "续费日期",
authResult: isEn ? "Auth Result" : "鉴权结果",
passed: isEn ? "Passed" : "通过",
restricted: isEn ? "Restricted" : "受限",
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
telegramHint: isEn
? "Send the command below to the polyweather bot to sync notifications and access."
: "将下方命令发送给polyweather机器人,实现全平台气象推送与权限同步。",
copyCommand: isEn ? "Copy command" : "复制命令",
paymentMgmt: isEn ? "Payment Management" : "支付管理",
paymentToken: isEn ? "Payment Token" : "支付币种",
primary: "Primary",
polygonChain: "Polygon Chain",
noWallet: isEn ? "No payout wallet bound yet." : "未绑定任何收件钱包",
bindExt:
isEn ? "Bind Browser Wallet (EVM Extension)" : "绑定浏览器钱包(EVM扩展)",
bindQr: isEn ? "Bind via QR (WalletConnect)" : "扫码绑定(WalletConnect",
walletConnectMissing: isEn
? "WalletConnect disabled: please configure"
: "未启用 WalletConnect:请配置",
unbind: isEn ? "Unbind" : "解绑",
unbindConfirm: isEn
? "Unbind wallet {address}? You can bind it again later."
: "确认解绑钱包 {address}?后续可重新绑定。",
unbindDone: isEn ? "Wallet unbound." : "钱包已解绑。",
unbindDonePrimary: isEn
? "Wallet unbound. New primary: {address}"
: "钱包已解绑,新的主钱包:{address}",
unbindFailed: isEn ? "Failed to unbind wallet" : "解绑钱包失败",
payNow: isEn ? "Subscribe & Activate" : "立即订阅并激活服务",
connectAndPay: isEn ? "Connect Wallet & Pay" : "连接钱包并支付",
loginBeforeBind: isEn
? "Please sign in before binding wallet."
: "请先登录后再绑定钱包。",
loginBeforePay: isEn
? "Please sign in before payment."
: "请先登录后再支付。",
bindFirstBeforePay: isEn
? "Please bind a wallet first."
: "请先绑定钱包。",
payNotReady: isEn
? "Payment service is not fully configured."
: "支付服务未配置完成。",
openBindFlow: isEn
? "Please bind a wallet first. Opening bind flow..."
: "请先完成钱包绑定,正在拉起绑定流程...",
walletBoundCreatingOrder: isEn
? "Wallet bound. Creating order and sending payment..."
: "钱包已绑定,正在创建订单并发起支付...",
proMember: "PRO MEMBER",
freeTier: "FREE TIER",
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
}),
[isEn],
);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -522,7 +611,12 @@ export function AccountCenter() {
* is missing or close to expiry. Throws if the user is not authenticated.
*/
const getValidAccessToken = useCallback(async (): Promise<string> => {
if (!supabaseReady) throw new Error("Supabase 未配置,无法获取登录凭证。");
if (!supabaseReady)
throw new Error(
isEn
? "Supabase is not configured. Unable to get auth token."
: "Supabase 未配置,无法获取登录凭证。",
);
const client = getSupabaseBrowserClient();
// First try the cached session.
const {
@@ -539,10 +633,14 @@ export function AccountCenter() {
if (refreshedToken) return refreshedToken;
throw new Error(
error?.message
? `登录会话已失效 (${error.message}),请退出后重新登录。`
: "登录会话已失效,请退出后重新登录。",
? isEn
? `Session expired (${error.message}). Please sign out and sign in again.`
: `登录会话已失效 (${error.message}),请退出后重新登录。`
: isEn
? "Session expired. Please sign out and sign in again."
: "登录会话已失效,请退出后重新登录。",
);
}, [supabaseReady]);
}, [isEn, supabaseReady]);
const buildAuthedHeaders = useCallback(
async (withJson = false): Promise<Record<string, string>> => {
@@ -764,19 +862,19 @@ export function AccountCenter() {
const displayName =
String(user?.user_metadata?.full_name || "").trim() ||
(email ? String(email).split("@")[0] : "") ||
"PolyWeather 用户";
copy.guestUser;
const initials = (displayName.slice(0, 2) || "PW").toUpperCase();
const joinedAt = formatTime(user?.created_at, "zh-CN");
const joinedAt = formatTime(user?.created_at, locale);
const isSubscribed = Boolean(backend?.subscription_active);
const expiryRaw = String(
backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "",
).trim();
const expiryFormatted = formatTime(expiryRaw, "zh-CN");
const expiryFormatted = formatTime(expiryRaw, locale);
const proExpiry = isSubscribed
? expiryFormatted !== "--"
? expiryFormatted
: expiryRaw || "已开通(待同步)"
: "暂无 Pro 订阅";
: expiryRaw || copy.proPendingSync
: copy.noProSubscription;
// Points Logic
const backendPointsRaw = Number(backend?.points);
@@ -1073,7 +1171,7 @@ export function AccountCenter() {
setPaymentError("");
setPaymentInfo("");
if (!isAuthenticated) {
setPaymentError("请先登录后再绑定钱包。");
setPaymentError(copy.loginBeforeBind);
return false;
}
@@ -1101,7 +1199,7 @@ export function AccountCenter() {
method: "eth_requestAccounts",
})) as string[];
const address = String(accounts?.[0] || "").toLowerCase();
if (!address) throw new Error("钱包账户为空");
if (!address) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
const existingWallet = boundWallets.find(
(w) => String(w.address || "").toLowerCase() === address,
@@ -1163,16 +1261,77 @@ export function AccountCenter() {
}
};
const handleUnbindWallet = async (address: string) => {
const target = String(address || "").toLowerCase();
if (!target) return;
if (!isAuthenticated) {
setPaymentError(copy.loginBeforeBind);
return;
}
const confirmed = window.confirm(
copy.unbindConfirm.replace("{address}", shortAddress(target)),
);
if (!confirmed) return;
setPaymentBusy(true);
setPaymentError("");
setPaymentInfo("");
try {
const headers = await buildAuthedHeaders(true);
const res = await fetch("/api/payments/wallets", {
method: "DELETE",
headers,
body: JSON.stringify({ address: target }),
});
const raw = await res.text();
if (!res.ok) {
let detail = raw;
try {
const parsed = JSON.parse(raw);
detail = String(parsed?.detail || parsed?.error || raw);
} catch {
// ignore
}
throw new Error(detail || `HTTP ${res.status}`);
}
let data: Record<string, unknown> = {};
try {
data = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
} catch {
data = {};
}
const newPrimary = String(data?.new_primary || "").toLowerCase();
if (selectedWallet === target) {
setSelectedWallet(newPrimary || "");
}
if (walletAddress === target) {
setWalletAddress(newPrimary || "");
}
await loadPaymentSnapshot();
setPaymentInfo(
newPrimary
? copy.unbindDonePrimary.replace("{address}", shortAddress(newPrimary))
: copy.unbindDone,
);
} catch (error) {
const message = normalizePaymentError(error).message;
setPaymentError(`${copy.unbindFailed}: ${message}`);
} finally {
setPaymentBusy(false);
}
};
const createIntentAndPay = async () => {
setPaymentError("");
setPaymentInfo("");
setLastTxHash("");
if (!isAuthenticated) {
setPaymentError("请先登录后再支付。");
setPaymentError(copy.loginBeforePay);
return;
}
if (!paymentConfig?.configured) {
setPaymentError("支付服务未配置完成。");
setPaymentError(copy.payNotReady);
return;
}
@@ -1180,7 +1339,7 @@ export function AccountCenter() {
selectedWallet || walletAddress || boundWallets[0]?.address || "",
).toLowerCase();
if (!fallbackWallet) {
setPaymentError("请先绑定钱包。");
setPaymentError(copy.bindFirstBeforePay);
return;
}
@@ -1193,7 +1352,7 @@ export function AccountCenter() {
method: "eth_requestAccounts",
})) as string[];
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
if (!activeAddress) throw new Error("钱包账户为空");
if (!activeAddress) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
const boundAddrSet = new Set(
boundWallets.map((row) => String(row.address || "").toLowerCase()),
@@ -1408,16 +1567,16 @@ export function AccountCenter() {
const handleOverlayCheckout = async () => {
if (!isAuthenticated) {
setPaymentError("请先登录后再支付。");
setPaymentError(copy.loginBeforePay);
return;
}
if (!hasPayingWallet) {
setPaymentInfo("请先完成钱包绑定,正在拉起绑定流程...");
setPaymentInfo(copy.openBindFlow);
const bound = await connectAndBindWallet(providerMode, {
openOverlayAfterBind: true,
});
if (!bound) return;
setPaymentInfo("钱包已绑定,正在创建订单并发起支付...");
setPaymentInfo(copy.walletBoundCreatingOrder);
await createIntentAndPay();
return;
}
@@ -1431,7 +1590,7 @@ export function AccountCenter() {
<div className="flex h-screen w-full items-center justify-center bg-[#0b0f1a]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-12 w-12 animate-spin text-blue-500" />
<p className="text-slate-400 font-medium">...</p>
<p className="text-slate-400 font-medium">{copy.loadingAccount}</p>
</div>
</div>
);
@@ -1449,7 +1608,8 @@ export function AccountCenter() {
<Link
href="/"
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-full text-slate-400 hover:text-white transition-all active:scale-90 group"
title="返回首页"
title={copy.backHome}
aria-label={copy.backHome}
>
<ChevronLeft
size={20}
@@ -1458,7 +1618,7 @@ export function AccountCenter() {
</Link>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
{copy.accountCenter}
</h1>
</div>
</div>
@@ -1468,7 +1628,7 @@ export function AccountCenter() {
onClick={() => setShowOverlay(true)}
className="flex items-center gap-2 px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 border border-yellow-500/30 text-yellow-500 rounded-xl text-sm transition-all animate-pulse"
>
<Crown size={16} /> Pro
<Crown size={16} /> {copy.upgradePro}
</button>
)}
<button
@@ -1482,21 +1642,21 @@ export function AccountCenter() {
) : (
<RefreshCw size={16} />
)}{" "}
{copy.refresh}
</button>
{isAuthenticated ? (
<button
onClick={() => void onSignOut()}
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/20 text-red-400 rounded-xl text-sm transition-all"
>
<LogOut size={16} /> 退
<LogOut size={16} /> {copy.signOut}
</button>
) : (
<Link
href="/auth/login?next=%2Faccount"
className="flex items-center gap-2 px-4 py-2 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/20 text-blue-400 rounded-xl text-sm transition-all"
>
<LogIn size={16} />
<LogIn size={16} /> {copy.signIn}
</Link>
)}
</div>
@@ -1521,11 +1681,11 @@ export function AccountCenter() {
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-tighter border ${isSubscribed ? "bg-blue-500/20 border-blue-500/40 text-blue-400" : "bg-slate-700/50 border-white/10 text-slate-500"}`}
>
{isSubscribed ? "PRO MEMBER" : "FREE TIER"}
{isSubscribed ? copy.proMember : copy.freeTier}
</span>
</div>
<p className="text-slate-500 font-mono text-sm mb-4">
{email || "游客用户"}
{email || copy.guestUser}
</p>
<div className="flex flex-wrap justify-center md:justify-start gap-4">
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
@@ -1535,14 +1695,17 @@ export function AccountCenter() {
</span>
</div>
<div className="flex items-center gap-1.5 text-slate-400 text-xs">
<Clock size={14} /> <span>: {joinedAt}</span>
<Clock size={14} />{" "}
<span>
{copy.joinedAt}: {joinedAt}
</span>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="px-6 py-4 bg-black/40 rounded-2xl border border-white/5 text-center min-w-[140px]">
<p className="text-[10px] text-slate-500 uppercase tracking-widest mb-1">
()
{copy.totalPoints}
</p>
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
<Coins size={16} className="text-yellow-500" />{" "}
@@ -1551,7 +1714,7 @@ export function AccountCenter() {
</div>
<div className="px-6 py-4 bg-blue-500/10 rounded-2xl border border-blue-500/20 text-center min-w-[140px]">
<p className="text-[10px] text-blue-400 uppercase tracking-widest mb-1 font-bold">
()
{copy.weeklyRank}
</p>
<p className="text-xl font-bold text-white flex items-center justify-center gap-2">
<Trophy size={16} className="text-amber-400" /> #{weeklyRank}
@@ -1564,7 +1727,8 @@ export function AccountCenter() {
<div className="lg:col-span-4 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 border border-indigo-500/30 rounded-[2.5rem] p-6 flex flex-col justify-between shadow-xl">
<div>
<h3 className="text-lg font-bold flex items-center gap-2 text-white mb-6">
<Sparkles size={20} className="text-yellow-400" />
<Sparkles size={20} className="text-yellow-400" />{" "}
{copy.weeklyRewards}
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/5">
@@ -1617,49 +1781,49 @@ export function AccountCenter() {
>
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
<h3 className="text-sm font-bold text-blue-400 uppercase tracking-widest mb-4">
{copy.membershipDetails}
</h3>
<InfoRow icon={ShieldCheck} label="鉴权模式" value="Supabase" />
<InfoRow icon={BarChart3} label="气象引擎" value="DEB + 多模型" />
<InfoRow icon={ShieldCheck} label={copy.authMode} value="Supabase" />
<InfoRow icon={BarChart3} label={copy.weatherEngine} value="DEB + 多模型" />
<InfoRow
icon={Zap}
label="今日内分析"
value={isSubscribed ? "深度版(含高温时段)" : "简版可见"}
label={copy.intradayAnalysis}
value={isSubscribed ? copy.deepMode : copy.compactVisible}
isPrimary={isSubscribed}
/>
<InfoRow
icon={Clock}
label="历史对账 + 未来日期分析"
value={isSubscribed ? "已开启" : "锁定"}
label={copy.historyFuture}
value={isSubscribed ? copy.enabled : copy.locked}
isPrimary={isSubscribed}
/>
<InfoRow
icon={Bot}
label="全平台智能气象推送"
value={isSubscribed ? "已开启" : "锁定"}
label={copy.smartPush}
value={isSubscribed ? copy.enabled : copy.locked}
isPrimary={isSubscribed}
/>
</section>
<section className="bg-white/5 border border-white/10 rounded-[2rem] p-6 space-y-3">
<h3 className="text-sm font-bold text-indigo-400 uppercase tracking-widest mb-4">
{copy.identityStatus}
</h3>
<InfoRow icon={Mail} label="绑定邮箱" value={email || "--"} />
<InfoRow icon={Mail} label={copy.boundEmail} value={email || "--"} />
<InfoRow
icon={LogIn}
label="登录方式"
label={copy.loginMethod}
value={user?.app_metadata?.provider?.toUpperCase() || "GOOGLE"}
/>
<InfoRow
icon={Clock}
label="续费日期"
label={copy.renewalDate}
value={proExpiry}
isPrimary
/>
<InfoRow
icon={UserCheck}
label="鉴权结果"
value={backend?.authenticated ? "通过" : "受限"}
label={copy.authResult}
value={backend?.authenticated ? copy.passed : copy.restricted}
/>
</section>
</div>
@@ -1685,7 +1849,7 @@ export function AccountCenter() {
onClose={() => setShowOverlay(false)}
payBusy={paymentBusy}
payLabel={
hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付"
hasPayingWallet ? copy.payNow : copy.connectAndPay
}
errorText={paymentError || undefined}
infoText={paymentInfo || undefined}
@@ -1708,10 +1872,10 @@ export function AccountCenter() {
/>
<div className="relative z-10">
<h3 className="text-lg font-bold mb-2 flex items-center gap-2 text-blue-400">
<Bot size={22} /> Telegram Bot
<Bot size={22} /> {copy.telegramBind}
</h3>
<p className="text-slate-400 text-sm mb-6">
polyweather机器人
{copy.telegramHint}
</p>
<div className="flex gap-2">
<code className="flex-grow bg-black/40 border border-white/10 p-4 rounded-xl font-mono text-xs text-blue-300 overflow-hidden text-ellipsis whitespace-nowrap">
@@ -1720,7 +1884,8 @@ export function AccountCenter() {
<button
onClick={() => handleCopy(bindCommand)}
className="p-4 bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg text-white"
title="复制命令"
title={copy.copyCommand}
aria-label={copy.copyCommand}
>
{copied ? <CheckCircle2 size={20} /> : <Copy size={20} />}
</button>
@@ -1732,12 +1897,12 @@ export function AccountCenter() {
<section className="w-full md:w-96 bg-white/5 border border-white/10 rounded-[2rem] p-8 flex flex-col justify-between">
<div>
<h3 className="text-blue-400 text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
<Wallet size={18} />
<Wallet size={18} /> {copy.paymentMgmt}
</h3>
{availableTokenList.length > 0 && (
<div className="mb-5">
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
{copy.paymentToken}
</p>
<div className="grid grid-cols-2 gap-2">
{availableTokenList.map((token) => {
@@ -1781,17 +1946,28 @@ export function AccountCenter() {
</span>
{w.is_primary && (
<span className="text-[8px] bg-blue-500 px-1 rounded">
Primary
{copy.primary}
</span>
)}
</div>
<div className="text-[10px]">Polygon Chain</div>
<div className="text-[10px]">{copy.polygonChain}</div>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={() => void handleUnbindWallet(w.address)}
disabled={paymentBusy}
className="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-[10px] font-semibold text-red-300 transition-all hover:bg-red-500/20 disabled:opacity-50"
>
<Minus size={12} />
{copy.unbind}
</button>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-slate-500 italic">
{copy.noWallet}
</p>
)}
</div>
@@ -1805,7 +1981,7 @@ export function AccountCenter() {
disabled={paymentBusy || !isAuthenticated}
className="w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
>
<PlusIcon className="w-4 h-4" /> EVM扩展
<PlusIcon className="w-4 h-4" /> {copy.bindExt}
</button>
<button
onClick={() => {
@@ -1817,11 +1993,11 @@ export function AccountCenter() {
}
className="w-full py-3 border border-cyan-400/30 bg-cyan-500/10 hover:bg-cyan-500/20 rounded-xl text-xs font-bold text-cyan-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
>
<CreditCard className="w-4 h-4" /> WalletConnect
<CreditCard className="w-4 h-4" /> {copy.bindQr}
</button>
{!walletConnectEnabled && (
<p className="text-[11px] text-slate-500">
WalletConnect
{copy.walletConnectMissing}
<code className="mx-1">
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
</code>
+59 -20
View File
@@ -17,6 +17,7 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { useI18n } from "@/hooks/useI18n";
type Mode = "login" | "signup";
@@ -26,6 +27,7 @@ type LoginClientProps = {
export function LoginClient({ nextPath }: LoginClientProps) {
const router = useRouter();
const { locale } = useI18n();
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -34,6 +36,42 @@ export function LoginClient({ nextPath }: LoginClientProps) {
const [infoText, setInfoText] = useState("");
const supabaseReady = hasSupabasePublicEnv();
const isEn = locale === "en-US";
const copy = {
backHome: isEn ? "Back to Home" : "返回首页",
subtitle: isEn
? "Explore weather details from every corner of the world"
: "探索世界每一个角落的气象细节",
googleOneClick: isEn
? "Continue with Google"
: "使用 Google 账号一键登录",
orEmail: isEn ? "Or continue with email" : "或使用邮箱",
login: isEn ? "Sign In" : "登录",
signup: isEn ? "Sign Up" : "注册",
passwordLoginPlaceholder: isEn ? "Enter password" : "输入密码",
passwordSignupPlaceholder: isEn
? "Set at least 6 characters"
: "设置至少 6 位密码",
loginSubmit: isEn ? "Start your weather journey" : "开启天气之旅",
signupSubmit: isEn ? "Create account now" : "立即创建账号",
loginHint: isEn
? "After signing in, your homepage will be personalized."
: "登录后将为您个性化定制首页数据",
signupHint: isEn
? "By signing up, you agree to our Terms of Service."
: "注册即代表同意我们的服务条款",
realtime: isEn ? "Realtime data" : "实时数据",
highPrecision: isEn ? "High-precision forecast" : "高精度预测",
supabaseMissing: isEn
? "Supabase is not configured. Sign-in is unavailable."
: "Supabase 未配置,无法使用登录",
needEmailPassword: isEn
? "Please enter email and password."
: "请输入邮箱和密码",
signupCheckEmail: isEn
? "Sign-up successful. Please verify your email before signing in."
: "注册成功,请检查邮箱并完成验证后登录。",
} as const;
useEffect(() => {
if (!supabaseReady) return;
@@ -53,7 +91,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
setErrorText("");
setInfoText("");
if (!supabaseReady) {
setErrorText("Supabase 未配置,无法使用登录");
setErrorText(copy.supabaseMissing);
return;
}
@@ -82,11 +120,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
setErrorText("");
setInfoText("");
if (!supabaseReady) {
setErrorText("Supabase 未配置,无法使用登录");
setErrorText(copy.supabaseMissing);
return;
}
if (!email.trim() || !password.trim()) {
setErrorText("请输入邮箱和密码");
setErrorText(copy.needEmailPassword);
return;
}
@@ -124,7 +162,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
router.replace(nextPath);
return;
}
setInfoText("注册成功,请检查邮箱并完成验证后登录。");
setInfoText(copy.signupCheckEmail);
} finally {
setLoading(false);
}
@@ -141,18 +179,17 @@ export function LoginClient({ nextPath }: LoginClientProps) {
<Link
href="/"
className="group absolute left-6 top-6 rounded-full border border-white/10 bg-white/5 p-2 text-slate-400 transition-all hover:bg-white/10 hover:text-white active:scale-90"
title="返回首页"
aria-label="返回首页"
title={copy.backHome}
aria-label={copy.backHome}
>
<ChevronLeft className="h-5 w-5 transition-transform group-hover:-translate-x-0.5" />
</Link>
<div className="mb-8 flex flex-col items-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-tr from-blue-500 to-indigo-400 shadow-lg shadow-blue-500/20">
<Cloud className="h-10 w-10 text-white" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-white">PolyWeather</h1>
<p className="mt-2 text-sm text-slate-400"></p>
<p className="mt-2 text-sm text-slate-400">{copy.subtitle}</p>
</div>
<button
@@ -162,13 +199,13 @@ export function LoginClient({ nextPath }: LoginClientProps) {
className="mb-6 flex w-full items-center justify-center rounded-xl bg-white px-4 py-3.5 font-semibold text-slate-900 shadow-lg transition-all duration-200 hover:bg-slate-100 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
>
<Chrome className="mr-3 h-5 w-5" />
使 Google
{copy.googleOneClick}
</button>
<div className="my-6 flex items-center">
<div className="h-[1px] flex-grow bg-white/10" />
<span className="px-4 text-xs font-medium uppercase tracking-widest text-slate-500">
使
{copy.orEmail}
</span>
<div className="h-[1px] flex-grow bg-white/10" />
</div>
@@ -183,7 +220,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
: "text-slate-400 hover:text-slate-200"
}`}
>
{copy.login}
</button>
<button
type="button"
@@ -194,7 +231,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
: "text-slate-400 hover:text-slate-200"
}`}
>
{copy.signup}
</button>
</div>
@@ -218,7 +255,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
minLength={6}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={isLogin ? "输入密码" : "设置至少 6 位密码"}
placeholder={
isLogin
? copy.passwordLoginPlaceholder
: copy.passwordSignupPlaceholder
}
className="w-full rounded-xl border border-white/10 bg-white/5 py-3.5 pl-12 pr-4 text-white placeholder:text-slate-600 transition-all focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
@@ -228,7 +269,7 @@ export function LoginClient({ nextPath }: LoginClientProps) {
disabled={loading}
className="group mt-8 flex w-full items-center justify-center rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 py-3.5 font-bold text-white shadow-xl shadow-blue-600/20 transition-all hover:from-blue-500 hover:to-indigo-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-70"
>
{isLogin ? "开启天气之旅" : "立即创建账号"}
{isLogin ? copy.loginSubmit : copy.signupSubmit}
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</button>
</form>
@@ -238,23 +279,21 @@ export function LoginClient({ nextPath }: LoginClientProps) {
<div className="mt-8 text-center">
<p className="text-xs text-slate-500">
{isLogin ? "登录后将为您个性化定制首页数据" : "注册即代表同意我们的服务条款"}
{isLogin ? copy.loginHint : copy.signupHint}
</p>
</div>
{!supabaseReady ? (
<p className="mt-3 text-center text-sm text-rose-300">
Supabase 使
</p>
<p className="mt-3 text-center text-sm text-rose-300">{copy.supabaseMissing}</p>
) : null}
</div>
<div className="absolute bottom-8 flex items-center gap-4 text-sm text-slate-600">
<span className="flex items-center">
<Sun className="mr-1 h-4 w-4" />
<Sun className="mr-1 h-4 w-4" /> {copy.realtime}
</span>
<span className="flex items-center">
<CloudRain className="mr-1 h-4 w-4" />
<CloudRain className="mr-1 h-4 w-4" /> {copy.highPrecision}
</span>
</div>
</div>
+80
View File
@@ -1119,6 +1119,86 @@ class PaymentContractCheckoutService:
verified_at=now_iso,
)
def unbind_wallet(self, user_id: str, address: str) -> Dict[str, Any]:
self._ensure_enabled()
normalized = _normalize_address(address)
if not normalized:
raise PaymentCheckoutError(400, "invalid wallet address")
# Must be an active wallet owned by current user.
self._require_user_wallet(user_id, normalized)
now_iso = _to_iso(_now_utc())
self._rest(
"PATCH",
"user_wallets",
params={
"user_id": f"eq.{user_id}",
"chain_id": f"eq.{self.chain_id}",
"address": f"eq.{normalized}",
},
payload={
"status": "revoked",
"is_primary": False,
"updated_at": now_iso,
},
prefer="return=representation",
allowed_status=[200],
)
# Ensure there is still an active primary wallet after unbind.
active_primary_rows = self._rest(
"GET",
"user_wallets",
params={
"select": "id,address",
"user_id": f"eq.{user_id}",
"chain_id": f"eq.{self.chain_id}",
"status": "eq.active",
"is_primary": "eq.true",
"limit": "1",
},
allowed_status=[200],
)
new_primary = ""
if isinstance(active_primary_rows, list) and active_primary_rows:
new_primary = _normalize_address(active_primary_rows[0].get("address") or "")
else:
active_wallet_rows = self._rest(
"GET",
"user_wallets",
params={
"select": "id,address",
"user_id": f"eq.{user_id}",
"chain_id": f"eq.{self.chain_id}",
"status": "eq.active",
"order": "verified_at.desc,updated_at.desc",
"limit": "1",
},
allowed_status=[200],
)
if isinstance(active_wallet_rows, list) and active_wallet_rows:
candidate = active_wallet_rows[0]
candidate_id = candidate.get("id")
candidate_addr = _normalize_address(candidate.get("address") or "")
if candidate_id and candidate_addr:
self._rest(
"PATCH",
"user_wallets",
params={"id": f"eq.{candidate_id}"},
payload={"is_primary": True, "updated_at": now_iso},
prefer="return=representation",
allowed_status=[200],
)
new_primary = candidate_addr
return {
"address": normalized,
"unbound": True,
"new_primary": new_primary or None,
}
def _select_plan(self, plan_code: str) -> Dict[str, Any]:
code = str(plan_code or "").strip().lower() or "pro_monthly"
row = self.plan_catalog.get(code)
+17
View File
@@ -261,6 +261,10 @@ class WalletVerifyRequest(BaseModel):
signature: str = Field(..., min_length=20)
class WalletUnbindRequest(BaseModel):
address: str = Field(..., min_length=8)
class CreatePaymentIntentRequest(BaseModel):
plan_code: str = Field(default="pro_monthly", min_length=2)
payment_mode: str = Field(default="strict")
@@ -1226,6 +1230,19 @@ async def payment_wallets(request: Request):
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.delete("/api/payments/wallets")
async def payment_wallet_unbind(request: Request, body: WalletUnbindRequest):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
return PAYMENT_CHECKOUT.unbind_wallet(
user_id=identity["user_id"],
address=body.address,
)
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/wallets/challenge")
async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest):
_assert_entitlement(request)