"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { User } from "@supabase/supabase-js"; import type { LucideIcon } from "lucide-react"; import { BadgeCheck, Bot, CheckCircle2, ChevronLeft, Clock, Copy, Crown, CreditCard, Fingerprint, Hash, Loader2, LogIn, LogOut, Mail, Wallet, RefreshCw, Shield, Sparkles, User as UserIcon, UserCheck, } from "lucide-react"; import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; type AuthMeResponse = { authenticated?: boolean; user_id?: string | null; email?: string | null; entitlement_mode?: string | null; auth_required?: boolean; subscription_required?: boolean; subscription_active?: boolean | null; }; type PaymentPlan = { plan_code: string; plan_id: number; amount_usdc: string; duration_days: number; }; type PaymentConfig = { enabled?: boolean; configured?: boolean; chain_id?: number; token_address?: string; token_decimals?: number; receiver_contract?: string; confirmations?: number; plans?: PaymentPlan[]; }; type BoundWallet = { chain_id: number; address: string; status: string; is_primary: boolean; verified_at?: string | null; }; type CreatedIntent = { intent?: { intent_id: string; order_id_hex: string; plan_code: string; amount_usdc: string; allowed_wallet?: string | null; }; tx_payload?: { chain_id: number; to: string; data: string; value: string; amount_units: string; token_address: string; }; }; declare global { interface Window { ethereum?: { request: (args: { method: string; params?: any[] | object }) => Promise; }; } } type InfoItemProps = { icon: LucideIcon; label: string; value: string; status?: "default" | "primary"; }; function formatTime(value: string | undefined | null, locale: string) { if (!value) return "--"; try { const dt = new Date(value); if (Number.isNaN(dt.getTime())) return "--"; return new Intl.DateTimeFormat(locale, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(dt); } catch { return "--"; } } function normalizeProvider(user: User | null) { const provider = String(user?.app_metadata?.provider || "").trim().toLowerCase(); if (provider) return provider; const providers = user?.app_metadata?.providers; if (Array.isArray(providers) && providers.length) { return String(providers[0] || "").trim().toLowerCase(); } return ""; } function shortAddress(address: string) { const text = String(address || ""); if (!text.startsWith("0x") || text.length < 12) return text || "--"; return `${text.slice(0, 8)}...${text.slice(-6)}`; } function toPaddedHex(value: bigint) { return value.toString(16).padStart(64, "0"); } function toPaddedAddress(address: string) { return String(address || "") .toLowerCase() .replace(/^0x/, "") .padStart(64, "0"); } function buildAllowanceCalldata(owner: string, spender: string) { // allowance(address owner, address spender) return `0xdd62ed3e${toPaddedAddress(owner)}${toPaddedAddress(spender)}`; } function buildApproveCalldata(spender: string, amount: bigint) { // approve(address spender, uint256 amount) return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`; } function InfoItem({ icon: Icon, label, value, status = "default" }: InfoItemProps) { return (
{label}
{value}
); } export function AccountCenter() { const router = useRouter(); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [errorText, setErrorText] = useState(""); const [copied, setCopied] = useState(false); const [updatedAt, setUpdatedAt] = useState(""); const [user, setUser] = useState(null); const [backend, setBackend] = useState(null); const [paymentConfig, setPaymentConfig] = useState(null); const [boundWallets, setBoundWallets] = useState([]); const [walletAddress, setWalletAddress] = useState(""); const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); const [selectedWallet, setSelectedWallet] = useState(""); const [paymentBusy, setPaymentBusy] = useState(false); const [paymentInfo, setPaymentInfo] = useState(""); const [paymentError, setPaymentError] = useState(""); const [lastIntentId, setLastIntentId] = useState(""); const [lastTxHash, setLastTxHash] = useState(""); const supabaseReady = hasSupabasePublicEnv(); const loadPaymentSnapshot = useCallback(async () => { if (!backend?.authenticated) { setPaymentConfig(null); setBoundWallets([]); return; } try { const [configRes, walletsRes] = await Promise.all([ fetch("/api/payments/config", { cache: "no-store" }), fetch("/api/payments/wallets", { cache: "no-store" }), ]); if (configRes.ok) { const configJson = (await configRes.json()) as PaymentConfig; setPaymentConfig(configJson); if (!selectedPlanCode && Array.isArray(configJson.plans) && configJson.plans.length) { setSelectedPlanCode(configJson.plans[0].plan_code); } } if (walletsRes.ok) { const walletsJson = (await walletsRes.json()) as { wallets?: BoundWallet[]; }; const wallets = Array.isArray(walletsJson.wallets) ? walletsJson.wallets : []; setBoundWallets(wallets); if (wallets.length && !selectedWallet) { setSelectedWallet(wallets[0].address); } } } catch { return; } }, [backend?.authenticated, selectedPlanCode, selectedWallet]); const loadSnapshot = useCallback(async () => { setErrorText(""); try { const userPromise = supabaseReady ? getSupabaseBrowserClient().auth.getUser() : Promise.resolve({ data: { user: null as User | null } }); const backendPromise = fetch("/api/auth/me", { cache: "no-store" }); const [userResult, backendResult] = await Promise.all([ userPromise, backendPromise, ]); setUser(userResult.data?.user ?? null); if (!backendResult.ok) { const raw = (await backendResult.text()).slice(0, 260); throw new Error(`HTTP ${backendResult.status} ${raw}`.trim()); } const backendJson = (await backendResult.json()) as AuthMeResponse; setBackend(backendJson); setUpdatedAt(new Date().toISOString()); } catch (error) { setErrorText(String(error)); } }, [supabaseReady]); useEffect(() => { let cancelled = false; const run = async () => { setLoading(true); await loadSnapshot(); if (!cancelled) setLoading(false); }; void run(); return () => { cancelled = true; }; }, [loadSnapshot]); useEffect(() => { void loadPaymentSnapshot(); }, [loadPaymentSnapshot]); const onRefresh = async () => { setRefreshing(true); await loadSnapshot(); await loadPaymentSnapshot(); setRefreshing(false); }; const onSignOut = async () => { if (supabaseReady) { try { await getSupabaseBrowserClient().auth.signOut(); } catch {} } router.replace("/"); }; const userId = backend?.user_id || user?.id || ""; const isAuthenticated = Boolean(userId); const email = backend?.email || user?.email || ""; const displayName = String(user?.user_metadata?.full_name || "").trim() || (email ? String(email).split("@")[0] : "") || "PolyWeather 用户"; const initials = (displayName.slice(0, 2) || "PW").toUpperCase(); const providerRaw = normalizeProvider(user); const provider = providerRaw ? providerRaw.toUpperCase() : "--"; const lastSignIn = formatTime( user?.last_sign_in_at, typeof navigator !== "undefined" ? navigator.language : "zh-CN", ); const updatedAtLabel = formatTime( updatedAt, typeof navigator !== "undefined" ? navigator.language : "zh-CN", ); const modeLabel = useMemo(() => { const mode = String(backend?.entitlement_mode || "").trim().toLowerCase(); if (mode === "supabase_required") return "Supabase 强制登录"; if (mode === "supabase_optional") return "Supabase 可选登录"; if (mode === "legacy_token") return "Legacy Token 鉴权"; if (mode === "disabled") return "未启用鉴权"; return "未知模式"; }, [backend?.entitlement_mode]); const backendStatus = useMemo(() => { if (backend?.authenticated) return "通过"; if (backend?.auth_required) return "未登录"; return "游客模式"; }, [backend?.authenticated, backend?.auth_required]); const subscriptionRequirement = backend?.subscription_required ? "已启用订阅校验" : "当前未强制订阅"; const subscriptionResult = !backend?.subscription_required ? "当前未强制订阅" : backend?.subscription_active ? "有效订阅" : "无有效订阅"; const bindCommand = userId ? `/bind ${userId}${email ? ` ${email}` : ""}` : "/bind "; const copyBindCommand = async () => { try { await navigator.clipboard.writeText(bindCommand); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch {} }; const waitForReceipt = async ( txHash: string, timeoutMs = 120000, pollMs = 3000, ) => { const eth = window.ethereum; if (!eth) { throw new Error("MetaMask not found"); } const started = Date.now(); while (Date.now() - started < timeoutMs) { const receipt = (await eth.request({ method: "eth_getTransactionReceipt", params: [txHash], })) as { status?: string } | null; if (receipt && receipt.status) { if (receipt.status === "0x1") return receipt; throw new Error(`transaction reverted: ${txHash}`); } await new Promise((resolve) => setTimeout(resolve, pollMs)); } throw new Error(`transaction confirmation timeout: ${txHash}`); }; const planList = paymentConfig?.plans || []; const selectedPlan = planList.find((p) => p.plan_code === selectedPlanCode) || planList[0]; const paymentFeatureReady = Boolean(paymentConfig?.enabled && paymentConfig?.configured); const connectAndBindWallet = async () => { setPaymentError(""); setPaymentInfo(""); if (!isAuthenticated) { setPaymentError("请先登录后再绑定钱包。"); return; } const eth = window.ethereum; if (!eth) { setPaymentError("未检测到 MetaMask,请先安装扩展。"); return; } setPaymentBusy(true); try { const accounts = (await eth.request({ method: "eth_requestAccounts", })) as string[]; const address = String(accounts?.[0] || "").toLowerCase(); if (!address) { throw new Error("钱包账户为空"); } setWalletAddress(address); const challengeRes = await fetch("/api/payments/wallets/challenge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ address }), }); if (!challengeRes.ok) { const raw = (await challengeRes.text()).slice(0, 300); throw new Error(`challenge failed: ${raw}`); } const challengeJson = (await challengeRes.json()) as { nonce?: string; message?: string; }; const message = String(challengeJson.message || ""); const nonce = String(challengeJson.nonce || ""); if (!message || !nonce) { throw new Error("challenge payload invalid"); } const signature = (await eth.request({ method: "personal_sign", params: [message, address], })) as string; const verifyRes = await fetch("/api/payments/wallets/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ address, nonce, signature }), }); if (!verifyRes.ok) { const raw = (await verifyRes.text()).slice(0, 300); throw new Error(`verify failed: ${raw}`); } setPaymentInfo(`钱包绑定成功: ${shortAddress(address)}`); await loadPaymentSnapshot(); } catch (error) { setPaymentError(String(error)); } finally { setPaymentBusy(false); } }; const createIntentAndPay = async () => { setPaymentError(""); setPaymentInfo(""); if (!isAuthenticated) { setPaymentError("请先登录后再支付。"); return; } if (!paymentConfig?.configured) { setPaymentError("支付服务未配置完成。"); return; } const eth = window.ethereum; if (!eth) { setPaymentError("未检测到 MetaMask。"); return; } const payingWallet = (selectedWallet || walletAddress || "").toLowerCase(); if (!payingWallet) { setPaymentError("请先绑定钱包。"); return; } setPaymentBusy(true); try { const currentChainIdHex = String( (await eth.request({ method: "eth_chainId" })) || "", ); const targetChainId = Number(paymentConfig.chain_id || 137); const targetChainHex = `0x${targetChainId.toString(16)}`; if (currentChainIdHex.toLowerCase() !== targetChainHex.toLowerCase()) { await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: targetChainHex }], }); } const createRes = await fetch("/api/payments/intents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ plan_code: selectedPlanCode || "pro_monthly", payment_mode: "strict", allowed_wallet: payingWallet, metadata: { source: "account_center" }, }), }); if (!createRes.ok) { const raw = (await createRes.text()).slice(0, 350); throw new Error(`create intent failed: ${raw}`); } const created = (await createRes.json()) as CreatedIntent; const intentId = String(created.intent?.intent_id || ""); const txPayload = created.tx_payload; if (!intentId || !txPayload?.to || !txPayload?.data) { throw new Error("intent payload invalid"); } setLastIntentId(intentId); const tokenAddress = String(txPayload.token_address || "").toLowerCase(); const amountUnits = BigInt(String(txPayload.amount_units || "0")); if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) { throw new Error("intent token/amount invalid"); } const allowanceHex = (await eth.request({ method: "eth_call", params: [ { to: tokenAddress, data: buildAllowanceCalldata(payingWallet, txPayload.to), }, "latest", ], })) as string; const allowance = BigInt(String(allowanceHex || "0x0")); if (allowance < amountUnits) { setPaymentInfo("检测到授权不足,正在发起 USDC 授权..."); const approveHash = (await eth.request({ method: "eth_sendTransaction", params: [ { from: payingWallet, to: tokenAddress, data: buildApproveCalldata(txPayload.to, amountUnits), value: "0x0", }, ], })) as string; await waitForReceipt(String(approveHash || "")); setPaymentInfo("USDC 授权成功,正在发起支付..."); } else { setPaymentInfo("授权额度充足,正在发起支付..."); } const txHash = (await eth.request({ method: "eth_sendTransaction", params: [ { from: payingWallet, to: txPayload.to, data: txPayload.data, value: txPayload.value || "0x0", }, ], })) as string; const txHashNorm = String(txHash || "").toLowerCase(); setLastTxHash(txHashNorm); const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet, }), }); if (!submitRes.ok) { const raw = (await submitRes.text()).slice(0, 350); throw new Error(`submit tx failed: ${raw}`); } const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tx_hash: txHashNorm }), }); if (!confirmRes.ok) { const raw = (await confirmRes.text()).slice(0, 350); setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`); throw new Error(`confirm pending: ${raw}`); } setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`); await loadSnapshot(); await loadPaymentSnapshot(); } catch (error) { setPaymentError(String(error)); } finally { setPaymentBusy(false); } }; return (

账户中心

管理您的身份、权限与 Bot 绑定

返回看板 {isAuthenticated ? ( ) : ( 登录 / 注册 )}
{errorText ? (
加载失败: {errorText}
) : null} {!supabaseReady ? (
NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 未配置。
) : null}
最近同步: {updatedAtLabel}
{initials}

{displayName}

{isAuthenticated ? "已登录" : "游客模式"}

{email || "--"}

当前角色

Free Tier

会员与权限

身份信息

钱包支付(P1 合约收款)

Chain #{paymentConfig?.chain_id || 137}
{!isAuthenticated ? (

请先登录,再绑定钱包并完成支付。

) : null} {isAuthenticated && !paymentFeatureReady ? (

支付服务未配置完成(需要后端开启 `POLYWEATHER_PAYMENT_*` 配置)。

) : null} {isAuthenticated && paymentFeatureReady ? (

已绑定钱包

{boundWallets.length ? ( boundWallets.map((wallet) => ( )) ) : (

暂无已绑定钱包。

)}

选择套餐并支付

{planList.map((plan) => ( ))}
{lastIntentId ? (

Intent: {lastIntentId}

) : null} {lastTxHash ? (

Tx: {shortAddress(lastTxHash)}

) : null}
) : null} {paymentInfo ? (

{paymentInfo}

) : null} {paymentError ? (

{paymentError}

) : null}

Bot 绑定

将下面命令发送至 Telegram Bot,即可把网页账户与机器人权限绑定,实现全平台气象推送。

{bindCommand}
); }