"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 { User as UserIcon, Shield, Fingerprint, Bot, RefreshCw, LogOut, ChevronLeft, Copy, CheckCircle2, UserCheck, Mail, Hash, LogIn, Clock, Crown, ExternalLink, Trophy, Coins, TrendingUp, Info, Wallet, Zap, Minus, ShieldCheck, BarChart3, Sparkles, ChevronRight, Loader2, CreditCard, type LucideIcon, } from "lucide-react"; import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay"; // --- Types --- type AuthMeResponse = { authenticated?: boolean; user_id?: string | null; email?: string | null; points?: number; weekly_points?: number; weekly_rank?: number | 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 PointsRedemptionConfig = { enabled?: boolean; points_per_usdc?: number; max_discount_usdc?: number; }; type PaymentConfig = { enabled?: boolean; configured?: boolean; chain_id?: number; token_address?: string; token_decimals?: number; receiver_contract?: string; confirmations?: number; points_redemption?: PointsRedemptionConfig; 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; }; } } // --- Helpers --- type InfoRowProps = { icon?: LucideIcon; label: string; value: string; isPrimary?: boolean; }; const InfoRow = ({ icon: Icon, label, value, isPrimary = false, }: InfoRowProps) => (
{Icon && }
{label}
{value}
); 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", }).format(dt); } catch { 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) { return `0xdd62ed3e${toPaddedAddress(owner)}${toPaddedAddress(spender)}`; } function buildApproveCalldata(spender: string, amount: bigint) { return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`; } // --- Main Component --- 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 [showOverlay, setShowOverlay] = useState(false); const [usePoints, setUsePoints] = useState(true); 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 buildAuthedHeaders = useCallback( async (withJson = false): Promise> => { const headers: Record = {}; if (withJson) headers["Content-Type"] = "application/json"; if (!supabaseReady) return headers; try { const { data: { session }, } = await getSupabaseBrowserClient().auth.getSession(); const accessToken = String(session?.access_token || "").trim(); if (accessToken) headers.Authorization = `Bearer ${accessToken}`; } catch { // no-op } return headers; }, [supabaseReady], ); const loadPaymentSnapshot = useCallback(async () => { if (!backend?.authenticated) { setPaymentConfig(null); setBoundWallets([]); return; } try { const authHeaders = await buildAuthedHeaders(false); const [configRes, walletsRes] = await Promise.all([ fetch("/api/payments/config", { cache: "no-store", headers: authHeaders, }), fetch("/api/payments/wallets", { cache: "no-store", headers: authHeaders, }), ]); if (configRes.ok) { const configJson = (await configRes.json()) as PaymentConfig; setPaymentConfig(configJson); if (!selectedPlanCode && 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 { // ignore } }, [ backend?.authenticated, buildAuthedHeaders, selectedPlanCode, selectedWallet, ]); const loadSnapshot = useCallback(async () => { setErrorText(""); try { const userPromise = supabaseReady ? getSupabaseBrowserClient().auth.getUser() : Promise.resolve({ data: { user: null as User | null } }); const authHeaders = await buildAuthedHeaders(false); const backendPromise = fetch("/api/auth/me", { cache: "no-store", headers: authHeaders, }); 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)); } }, [buildAuthedHeaders, 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 { // ignore } } router.replace("/"); }; // --- Derived State --- 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 joinedAt = formatTime(user?.created_at, "zh-CN"); const isSubscribed = Boolean(backend?.subscription_active); const proExpiry = user?.user_metadata?.pro_expiry || "暂无 Pro 订阅"; // Points Logic const backendPointsRaw = Number(backend?.points); const metadataPointsRaw = Number( user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0, ); const metadataPointsSafe = Number.isFinite(metadataPointsRaw) ? metadataPointsRaw : 0; const pointsRaw = Number.isFinite(backendPointsRaw) ? Math.max(backendPointsRaw, metadataPointsSafe) : metadataPointsSafe; const backendWeeklyPointsRaw = Number(backend?.weekly_points); const metadataWeeklyPointsRaw = Number(user?.user_metadata?.weekly_points ?? 0); const weeklyPointsRaw = Number.isFinite(backendWeeklyPointsRaw) ? backendWeeklyPointsRaw : metadataWeeklyPointsRaw; const weeklyRankRaw = backend?.weekly_rank ?? user?.user_metadata?.weekly_rank; const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0; const weeklyPoints = Number.isFinite(weeklyPointsRaw) ? Math.max(0, weeklyPointsRaw) : 0; const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw); const planList = paymentConfig?.plans || []; const monthlyPlanList = planList.filter( (plan) => String(plan.plan_code || "") .trim() .toLowerCase() === "pro_monthly", ); const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList; const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; const paymentFeatureReady = Boolean( paymentConfig?.enabled && paymentConfig?.configured, ); const hasPayingWallet = Boolean( String( selectedWallet || walletAddress || boundWallets[0]?.address || "", ).trim(), ); const billing = useMemo(() => { const parsedPlanAmount = Number(selectedPlan?.amount_usdc ?? 5); const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 5; const pointsCfg = paymentConfig?.points_redemption || {}; const pointsEnabled = pointsCfg.enabled !== false; const pointsPerUsdcRaw = Number(pointsCfg.points_per_usdc ?? 500); const pointsPerUsdc = Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 ? Math.floor(pointsPerUsdcRaw) : 500; const maxDiscountRaw = Number(pointsCfg.max_discount_usdc ?? 3); const maxDiscountUsdc = Math.max( 0, Math.min( Math.floor(Number.isFinite(maxDiscountRaw) ? maxDiscountRaw : 3), Math.floor(planAmount), ), ); const maxRedeemablePoints = pointsPerUsdc * maxDiscountUsdc; const actualRedeem = pointsEnabled ? Math.min(totalPoints, maxRedeemablePoints) : 0; const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); const pointsUsed = discountUnits * pointsPerUsdc; const canRedeem = pointsEnabled && maxDiscountUsdc > 0 && totalPoints >= pointsPerUsdc; const applyDiscount = usePoints && canRedeem && pointsUsed > 0; return { planAmount, pointsEnabled, pointsPerUsdc, maxDiscountUsdc, pointsUsed, discountAmount: discountUnits, payAmount: planAmount - (applyDiscount ? discountUnits : 0), canRedeem, }; }, [ paymentConfig?.points_redemption, selectedPlan?.amount_usdc, totalPoints, usePoints, ]); const bindCommand = userId ? `/bind ${userId}${email ? ` ${email}` : ""}` : "/bind "; const handleCopy = (text: string) => { navigator.clipboard.writeText(text).then(() => { setCopied(true); window.setTimeout(() => setCopied(false), 2000); }); }; // --- Payment Logic (preserved) --- 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 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("钱包账户为空"); const authHeaders = await buildAuthedHeaders(true); if (!authHeaders.Authorization) throw new Error("登录会话失效,请重新登录后再绑定钱包。"); setWalletAddress(address); const challengeRes = await fetch("/api/payments/wallets/challenge", { method: "POST", headers: authHeaders, 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: authHeaders, 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 = String( selectedWallet || walletAddress || boundWallets[0]?.address || "", ).toLowerCase(); if (!payingWallet) { setPaymentError("请先绑定钱包。"); return; } setPaymentBusy(true); try { const authHeaders = await buildAuthedHeaders(true); if (!authHeaders.Authorization) throw new Error("登录会话失效,请重新登录后再支付。"); 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: authHeaders, body: JSON.stringify({ plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "strict", allowed_wallet: payingWallet, use_points: billing.canRedeem && usePoints, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, 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: authHeaders, 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: authHeaders, 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); } }; const handleOverlayCheckout = async () => { if (!isAuthenticated) { setPaymentError("请先登录后再支付。"); return; } if (!hasPayingWallet) { await connectAndBindWallet(); return; } await createIntentAndPay(); }; // --- Render --- if (loading && !refreshing) { return (

加载账户信息中...

); } return (
{/* Aurora Shadows */}
{/* Header */}

账户中心

{!isSubscribed && !showOverlay && paymentFeatureReady && ( )} {isAuthenticated ? ( ) : ( 登录 )}
{/* User Card */}
{initials}

{displayName}

{isSubscribed ? "PRO MEMBER" : "FREE TIER"}

{email || "游客用户"}

{" "} {userId ? `${userId.substring(0, 12)}...` : "--"}
加入时间: {joinedAt}

总积分 (荣誉)

{" "} {totalPoints.toLocaleString()}

周排行 (竞技)

#{weeklyRank}

{/* Weekly Ranking Motivation */}

周榜奖励

1
{" "} Top 1
+500 pts & 7D Pro
2
{" "} Top 2-3
+300 pts & 3D Pro
4
{" "} Top 4-10
+150 pts

积分规则:群内有效发言(自动防刷检测)。每周一零点结算并重置周积分榜。

{/* Subscription Info & Paywall */}

会员权限详情

身份状态

{/* Paywall Mask */} {!isSubscribed && showOverlay && (
setUsePoints((prev) => !prev)} billing={{ pointsEnabled: billing.pointsEnabled, isEligible: billing.canRedeem, pointsUsed: billing.pointsUsed, discountAmount: billing.discountAmount, finalPrice: billing.payAmount, maxDiscountUsd: billing.maxDiscountUsdc, pointsPerUsd: billing.pointsPerUsdc, }} onPay={() => void handleOverlayCheckout()} onClose={() => setShowOverlay(false)} payBusy={paymentBusy} payLabel={ hasPayingWallet ? "立即订阅并激活服务" : "连接钱包并支付" } errorText={paymentError || undefined} infoText={paymentInfo || undefined} faqHref="/account" />
)}
{/* Telegram Bot Section */}

Telegram Bot 绑定

将下方命令发送给到群聊,实现全平台气象推送与权限同步。

{bindCommand}
{/* Payment Details / Wallet Management */}

支付管理

{boundWallets.length ? (
{boundWallets.map((w) => (
{shortAddress(w.address)} {w.is_primary && ( Primary )}
Polygon Chain
))}
) : (

未绑定任何收件钱包

)}
PolyWeather Global Meteorological Engine · Powered by AI
); } function PlusIcon({ className }: { className?: string }) { return ( ); }