"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import dynamic from "next/dynamic"; 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"; const UnlockProOverlay = dynamic( () => import("@/components/subscription/UnlockProOverlay").then( (module) => module.UnlockProOverlay, ), { ssr: false, loading: () => null, }, ); // --- 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; subscription_plan_code?: string | null; subscription_starts_at?: string | null; subscription_expires_at?: string | null; }; type PaymentPlan = { plan_code: string; plan_id: number; amount_usdc: string; duration_days: number; }; type PaymentTokenOption = { code: string; symbol: string; name: string; address: string; decimals: number; receiver_contract?: string; is_default?: boolean; }; 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; default_token_address?: string; tokens?: PaymentTokenOption[]; 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; token_symbol?: string; token_decimals?: number; }; }; type IntentStatusResponse = { intent?: { intent_id?: string; status?: string; tx_hash?: string | null; }; }; declare global { interface Window { ethereum?: EvmProvider; } } type EvmProvider = { request: (args: { method: string; params?: any[] | object }) => Promise; providers?: EvmProvider[]; connect?: (args?: any) => Promise; disconnect?: () => Promise; session?: unknown; isMetaMask?: boolean; isRabby?: boolean; isOkxWallet?: boolean; isBitKeep?: boolean; }; type ProviderMode = "auto" | "walletconnect"; type ProviderSelection = { provider: EvmProvider; label: string; mode: ProviderMode; }; type ConnectBindOptions = { openOverlayAfterBind?: boolean; }; const WALLETCONNECT_PROJECT_ID = String( process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "", ).trim(); const WALLETCONNECT_POLYGON_RPC_URL = String( process.env.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL || "https://polygon-bor-rpc.publicnode.com", ).trim(); const TELEGRAM_GROUP_URL = String( process.env.NEXT_PUBLIC_TELEGRAM_GROUP_URL || "https://t.me/+nMG7SjziUKYyZmM1", ).trim(); const SUBSCRIPTION_HELP_HREF = "/subscription-help"; let walletConnectProviderCache: EvmProvider | null = null; let walletConnectProviderChainId: number | null = null; function isWalletConnectResetError(error: unknown): boolean { const source = error as any; const message = String( source?.shortMessage || source?.message || source?.reason || source?.data?.message || source?.cause?.message || source?.error?.message || (error instanceof Error ? error.message : "") || (typeof error === "string" ? error : ""), ).toLowerCase(); return ( message.includes("connection request reset") || message.includes("pairing aborted") || message.includes("pairing attempt") || message.includes("unable to connect") ); } async function resetWalletConnectProvider(): Promise { if (walletConnectProviderCache?.disconnect) { try { await walletConnectProviderCache.disconnect(); } catch { // ignore } } walletConnectProviderCache = null; walletConnectProviderChainId = null; } // --- 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 getEvmProvider(): EvmProvider | null { if (typeof window === "undefined") return null; const root = window.ethereum; if (!root) return null; const candidates = Array.isArray(root.providers) ? root.providers.filter( (p): p is EvmProvider => !!p && typeof p.request === "function", ) : []; if (candidates.length) { return candidates.find((p) => p.isMetaMask) || candidates[0]; } return typeof root.request === "function" ? root : null; } function getEvmWalletLabel(provider: EvmProvider | null): string { if (!provider) return "EVM 钱包"; if (provider.isMetaMask) return "MetaMask"; if (provider.isRabby) return "Rabby"; if (provider.isOkxWallet) return "OKX Wallet"; if (provider.isBitKeep) return "Bitget Wallet"; return "EVM 钱包"; } async function getWalletConnectProvider( chainId: number, rpcUrl: string, ): Promise { if (!WALLETCONNECT_PROJECT_ID) { throw new Error( "WalletConnect 未配置:缺少 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID。", ); } if (walletConnectProviderCache && walletConnectProviderChainId === chainId) { return walletConnectProviderCache; } const { EthereumProvider } = await import("@walletconnect/ethereum-provider"); const rpcMap: Record = { [chainId]: rpcUrl || WALLETCONNECT_POLYGON_RPC_URL, }; const origin = typeof window !== "undefined" ? window.location.origin : "https://polyweather-pro.vercel.app"; const provider = (await EthereumProvider.init({ projectId: WALLETCONNECT_PROJECT_ID, chains: [chainId], optionalChains: [chainId], showQrModal: true, methods: [ "eth_sendTransaction", "personal_sign", "eth_signTypedData", "eth_signTypedData_v4", "eth_sign", "eth_call", "eth_chainId", "eth_accounts", "eth_requestAccounts", ], events: ["accountsChanged", "chainChanged", "disconnect"], rpcMap, metadata: { name: "PolyWeather", description: "PolyWeather Pro checkout", url: origin, icons: [`${origin}/favicon.ico`], }, })) as unknown as EvmProvider; walletConnectProviderCache = provider; walletConnectProviderChainId = chainId; return provider; } 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)}`; } function buildBalanceOfCalldata(owner: string) { return `0x70a08231${toPaddedAddress(owner)}`; } function formatTokenUnits(amount: bigint, decimals: number) { const safeDecimals = Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : 6; const base = 10n ** BigInt(safeDecimals); const whole = amount / base; const fraction = amount % base; if (fraction === 0n) return whole.toString(); const rawFraction = fraction.toString().padStart(safeDecimals, "0"); const trimmed = rawFraction.replace(/0+$/, ""); return `${whole.toString()}.${trimmed}`; } type NormalizedPaymentError = { message: string; pending: boolean; userRejected: boolean; }; function normalizePaymentError(error: unknown): NormalizedPaymentError { const source = error as any; const code = Number( source?.code ?? source?.error?.code ?? source?.data?.code ?? source?.cause?.code ?? NaN, ); const messageCandidates = [ source?.shortMessage, source?.message, source?.reason, source?.data?.message, source?.cause?.message, source?.error?.message, error instanceof Error ? error.message : "", typeof error === "string" ? error : "", ]; const rawMessage = messageCandidates .find( (item) => typeof item === "string" && item.trim() && item.trim().toLowerCase() !== "[object object]", ) ?.trim(); const lower = String(rawMessage || "").toLowerCase(); if (lower.includes("confirm pending") || lower.includes("payment pending timeout")) { return { message: "链上交易已提交,正在确认中,请稍后刷新查看状态。", pending: true, userRejected: false, }; } if (isWalletConnectResetError(error)) { return { message: "WalletConnect 连接已重置,请重新扫码连接;若仍失败,请先在钱包里断开旧连接后再试。", pending: false, userRejected: false, }; } const userRejected = code === 4001 || /user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消|签名请求已拒绝/.test( lower, ); if (userRejected) { return { message: "你已取消钱包操作。", pending: false, userRejected: true, }; } const insufficientGas = (code === -32000 && /insufficient funds/.test(lower) && /(gas|fee|native|pol|matic)/.test(lower)) || /not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/.test( lower, ); if (insufficientGas) { return { message: "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。", pending: false, userRejected: false, }; } if (rawMessage) { return { message: rawMessage, pending: false, userRejected: false, }; } try { return { message: JSON.stringify(error), pending: false, userRejected: false, }; } catch { return { message: "发生未知错误,请稍后重试。", pending: false, userRejected: false, }; } } // --- 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 [selectedTokenAddress, setSelectedTokenAddress] = useState(""); const [selectedWallet, setSelectedWallet] = useState(""); const [providerMode, setProviderMode] = useState("auto"); 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 walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); /** * Returns a valid access token, refreshing the session if the stored one * is missing or close to expiry. Throws if the user is not authenticated. */ const getValidAccessToken = useCallback(async (): Promise => { if (!supabaseReady) throw new Error("Supabase 未配置,无法获取登录凭证。"); const client = getSupabaseBrowserClient(); // First try the cached session. const { data: { session: cached }, } = await client.auth.getSession(); const cachedToken = String(cached?.access_token || "").trim(); if (cachedToken) return cachedToken; // Session missing or expired — force a refresh. const { data: { session: refreshed }, error, } = await client.auth.refreshSession(); const refreshedToken = String(refreshed?.access_token || "").trim(); if (refreshedToken) return refreshedToken; throw new Error( error?.message ? `登录会话已失效 (${error.message}),请退出后重新登录。` : "登录会话已失效,请退出后重新登录。", ); }, [supabaseReady]); const buildAuthedHeaders = useCallback( async (withJson = false): Promise> => { const headers: Record = {}; if (withJson) headers["Content-Type"] = "application/json"; if (!supabaseReady) return headers; try { const token = await getValidAccessToken(); headers.Authorization = `Bearer ${token}`; } catch { // Non-authenticated page load — silently skip. } return headers; }, [supabaseReady, getValidAccessToken], ); const resolvePaymentProvider = useCallback( async (mode: ProviderMode = "auto"): Promise => { const targetChainId = Number(paymentConfig?.chain_id || 137); if (mode !== "walletconnect") { const injected = getEvmProvider(); if (injected) { return { provider: injected, label: getEvmWalletLabel(injected), mode: "auto", }; } } if (!walletConnectEnabled) { throw new Error( "未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。", ); } const wcProvider = await getWalletConnectProvider( targetChainId, WALLETCONNECT_POLYGON_RPC_URL, ); const existingAccounts = (await wcProvider .request({ method: "eth_accounts" }) .catch(() => [])) as string[]; if (!Array.isArray(existingAccounts) || existingAccounts.length === 0) { if (typeof wcProvider.connect === "function") { try { await wcProvider.connect({ chains: [targetChainId] }); } catch (err) { if (!isWalletConnectResetError(err)) throw err; await resetWalletConnectProvider(); const freshProvider = await getWalletConnectProvider( targetChainId, WALLETCONNECT_POLYGON_RPC_URL, ); if (typeof freshProvider.connect === "function") { await freshProvider.connect({ chains: [targetChainId] }); } return { provider: freshProvider, label: "WalletConnect", mode: "walletconnect", }; } } } return { provider: wcProvider, label: "WalletConnect", mode: "walletconnect", }; }, [paymentConfig?.chain_id, walletConnectEnabled], ); const loadPaymentSnapshot = useCallback(async () => { if (!backend?.authenticated) { setPaymentConfig(null); setBoundWallets([]); return; } try { const authHeadersPromise = buildAuthedHeaders(false); const [configRes, walletsRes] = await Promise.all([ authHeadersPromise.then((headers) => fetch("/api/payments/config", { cache: "no-store", headers, }), ), authHeadersPromise.then((headers) => fetch("/api/payments/wallets", { cache: "no-store", headers, }), ), ]); if (configRes.ok) { const configJson = (await configRes.json()) as PaymentConfig; setPaymentConfig(configJson); if (!selectedPlanCode && configJson.plans?.length) { setSelectedPlanCode(configJson.plans[0].plan_code); } const tokenOptions = Array.isArray(configJson.tokens) ? configJson.tokens.filter( (row) => typeof row?.address === "string" && String(row.address).startsWith("0x"), ) : []; const defaultTokenAddress = String( configJson.default_token_address || tokenOptions.find((row) => row.is_default)?.address || tokenOptions[0]?.address || configJson.token_address || "", ).toLowerCase(); if (defaultTokenAddress) { setSelectedTokenAddress((prev) => prev || defaultTokenAddress); } } 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 authHeadersPromise = buildAuthedHeaders(false); const backendPromise = authHeadersPromise.then((headers) => fetch("/api/auth/me", { cache: "no-store", headers, }), ); 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 (walletConnectProviderCache?.disconnect) { try { await walletConnectProviderCache.disconnect(); } catch { // ignore } walletConnectProviderCache = null; walletConnectProviderChainId = null; } 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 expiryRaw = String( backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", ).trim(); const expiryFormatted = formatTime(expiryRaw, "zh-CN"); const proExpiry = isSubscribed ? expiryFormatted !== "--" ? expiryFormatted : expiryRaw || "已开通(待同步)" : "暂无 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 availableTokenList: PaymentTokenOption[] = useMemo(() => { const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : []; const clean = configured .filter( (row) => row && typeof row.address === "string" && row.address.startsWith("0x"), ) .map((row) => ({ ...row, address: String(row.address).toLowerCase(), symbol: String(row.symbol || "USDC"), name: String(row.name || row.symbol || "USDC"), code: String(row.code || "usdc"), decimals: Number.isFinite(Number(row.decimals)) ? Number(row.decimals) : Number(paymentConfig?.token_decimals ?? 6), })); if (clean.length) return clean; const fallbackAddress = String( paymentConfig?.token_address || "", ).toLowerCase(); if (!fallbackAddress.startsWith("0x")) return []; return [ { code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress, decimals: Number(paymentConfig?.token_decimals ?? 6), receiver_contract: paymentConfig?.receiver_contract, is_default: true, }, ]; }, [paymentConfig]); const resolvedSelectedTokenAddress = String( selectedTokenAddress || paymentConfig?.default_token_address || availableTokenList.find((row) => row.is_default)?.address || availableTokenList[0]?.address || paymentConfig?.token_address || "", ).toLowerCase(); const selectedPaymentToken = availableTokenList.find( (row) => row.address === resolvedSelectedTokenAddress, ) || availableTokenList[0]; const selectedTokenLabel = selectedPaymentToken?.symbol || (resolvedSelectedTokenAddress.startsWith("0x") ? shortAddress(resolvedSelectedTokenAddress) : "USDC"); 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 = getEvmProvider(); if (!eth) throw new Error("No EVM wallet provider 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 pollIntentUntilConfirmed = useCallback( async ( intentId: string, authHeaders: Record, txHashHint = "", timeoutMs = 180000, pollMs = 5000, ) => { const startedAt = Date.now(); const shortTx = shortAddress(txHashHint); while (Date.now() - startedAt < timeoutMs) { const statusRes = await fetch(`/api/payments/intents/${intentId}`, { method: "GET", headers: authHeaders, cache: "no-store", }); if (!statusRes.ok) { if (statusRes.status >= 500 || statusRes.status === 429) { await new Promise((resolve) => setTimeout(resolve, pollMs)); continue; } const raw = (await statusRes.text()).slice(0, 260); throw new Error(`query intent failed: ${raw}`); } const statusJson = (await statusRes.json()) as IntentStatusResponse; const intent = statusJson.intent || {}; const status = String(intent.status || "").toLowerCase(); const txHash = String(intent.tx_hash || txHashHint || "").toLowerCase(); if (status === "confirmed") { setPaymentError(""); setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`); await loadSnapshot(); await loadPaymentSnapshot(); return; } if (status === "failed" || status === "cancelled" || status === "expired") { throw new Error(`payment ${status}`); } setPaymentInfo( `交易已提交: ${shortTx},正在链上确认(状态: ${status || "submitted"})...`, ); await new Promise((resolve) => setTimeout(resolve, pollMs)); } throw new Error("payment pending timeout"); }, [loadPaymentSnapshot, loadSnapshot], ); const signBindMessage = async ( eth: EvmProvider, address: string, message: string, ): Promise => { try { return (await eth.request({ method: "personal_sign", params: [message, address], })) as string; } catch { // Some injected wallets still use the reversed param order. return (await eth.request({ method: "personal_sign", params: [address, message], })) as string; } }; const ensureTargetChain = async ( eth: EvmProvider, targetChainId: number, ): Promise => { const currentChainIdHex = String( (await eth.request({ method: "eth_chainId" })) || "", ); const targetChainHex = `0x${targetChainId.toString(16)}`; if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return; try { await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: targetChainHex }], }); } catch (err: any) { const code = Number(err?.code); if (code !== 4902 || targetChainId !== 137) throw err; await eth.request({ method: "wallet_addEthereumChain", params: [ { chainId: "0x89", chainName: "Polygon Mainnet", nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 }, rpcUrls: ["https://polygon-rpc.com"], blockExplorerUrls: ["https://polygonscan.com"], }, ], }); } }; const connectAndBindWallet = async ( mode: ProviderMode = "auto", options: ConnectBindOptions = {}, ): Promise => { setPaymentError(""); setPaymentInfo(""); if (!isAuthenticated) { setPaymentError("请先登录后再绑定钱包。"); return false; } setPaymentBusy(true); try { const providerSelection = await resolvePaymentProvider(mode); const eth = providerSelection.provider; const walletLabel = providerSelection.label; // Ensure we have a valid token BEFORE opening the wallet modal. let accessToken: string; try { accessToken = await getValidAccessToken(); } catch (tokenErr) { setPaymentError(normalizePaymentError(tokenErr).message); setPaymentBusy(false); return false; } const authHeaders: Record = { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }; const accounts = (await eth.request({ method: "eth_requestAccounts", })) as string[]; const address = String(accounts?.[0] || "").toLowerCase(); if (!address) throw new Error("钱包账户为空"); const existingWallet = boundWallets.find( (w) => String(w.address || "").toLowerCase() === address, ); if (existingWallet) { setWalletAddress(address); setSelectedWallet(address); setPaymentInfo( `${walletLabel} 已绑定: ${shortAddress(address)}。现在可点击“立即订阅并激活服务”。`, ); if (options.openOverlayAfterBind) setShowOverlay(true); setPaymentBusy(false); return true; } 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 signBindMessage(eth, address, message); 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( `${walletLabel} 绑定成功: ${shortAddress(address)}。现在可点击“立即订阅并激活服务”。`, ); setProviderMode(providerSelection.mode); if (options.openOverlayAfterBind) setShowOverlay(true); await loadPaymentSnapshot(); return true; } catch (error) { setPaymentInfo(""); setPaymentError(normalizePaymentError(error).message); return false; } finally { setPaymentBusy(false); } }; const createIntentAndPay = async () => { setPaymentError(""); setPaymentInfo(""); setLastTxHash(""); if (!isAuthenticated) { setPaymentError("请先登录后再支付。"); return; } if (!paymentConfig?.configured) { setPaymentError("支付服务未配置完成。"); return; } const fallbackWallet = String( selectedWallet || walletAddress || boundWallets[0]?.address || "", ).toLowerCase(); if (!fallbackWallet) { setPaymentError("请先绑定钱包。"); return; } setPaymentBusy(true); let approvedInThisRun = false; try { const providerSelection = await resolvePaymentProvider(providerMode); const eth = providerSelection.provider; const activeAccounts = (await eth.request({ method: "eth_requestAccounts", })) as string[]; const activeAddress = String(activeAccounts?.[0] || "").toLowerCase(); if (!activeAddress) throw new Error("钱包账户为空"); const boundAddrSet = new Set( boundWallets.map((row) => String(row.address || "").toLowerCase()), ); if (boundAddrSet.size > 0 && !boundAddrSet.has(activeAddress)) { throw new Error( `当前连接钱包 ${shortAddress(activeAddress)} 未绑定,请先绑定该地址后支付。`, ); } const payingWallet = boundAddrSet.has(activeAddress) ? activeAddress : fallbackWallet; setSelectedWallet(payingWallet); setProviderMode(providerSelection.mode); // Ensure we have a valid token BEFORE switching chain / sending tx. let accessToken: string; try { accessToken = await getValidAccessToken(); } catch (tokenErr) { setPaymentError(normalizePaymentError(tokenErr).message); setPaymentBusy(false); return; } const authHeaders: Record = { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, }; const targetChainId = Number(paymentConfig.chain_id || 137); await ensureTargetChain(eth, targetChainId); 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, token_address: resolvedSelectedTokenAddress || undefined, 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 tokenSymbol = String( txPayload.token_symbol || selectedPaymentToken?.symbol || selectedTokenLabel || "USDC", ); const tokenDecimals = Number( txPayload.token_decimals ?? selectedPaymentToken?.decimals ?? paymentConfig?.token_decimals ?? 6, ); const balanceHex = (await eth.request({ method: "eth_call", params: [ { to: tokenAddress, data: buildBalanceOfCalldata(payingWallet), }, "latest", ], })) as string; const tokenBalance = BigInt(String(balanceHex || "0x0")); if (tokenBalance < amountUnits) { const need = formatTokenUnits(amountUnits, tokenDecimals); const have = formatTokenUnits(tokenBalance, tokenDecimals); throw new Error( `支付代币余额不足:需要 ${need} ${tokenSymbol},当前 ${have} ${tokenSymbol}。请确认你钱包里持有该支付币种。`, ); } 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(`检测到授权不足,正在发起 ${tokenSymbol} 授权...`); 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 || "")); approvedInThisRun = true; setPaymentInfo(`${tokenSymbol} 授权成功,正在发起支付...`); } 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); const lowerRaw = raw.toLowerCase(); const maybePending = (confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) || confirmRes.status === 408 || (confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially"))); if (maybePending) { setPaymentInfo( `交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`, ); await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm); return; } throw new Error(`confirm failed: ${raw}`); } setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`); await loadSnapshot(); await loadPaymentSnapshot(); } catch (error) { const normalized = normalizePaymentError(error); if (normalized.pending) { setPaymentError(normalized.message); } else if (normalized.userRejected) { setPaymentInfo( approvedInThisRun ? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。` : "", ); setPaymentError(normalized.message); } else { setPaymentInfo( approvedInThisRun ? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。` : "", ); setPaymentError(normalized.message); } } finally { setPaymentBusy(false); } }; const handleOverlayCheckout = async () => { if (!isAuthenticated) { setPaymentError("请先登录后再支付。"); return; } if (!hasPayingWallet) { setPaymentInfo("请先完成钱包绑定,正在拉起绑定流程..."); const bound = await connectAndBindWallet(providerMode, { openOverlayAfterBind: true, }); if (!bound) return; setPaymentInfo("钱包已绑定,正在创建订单并发起支付..."); await createIntentAndPay(); 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 积分 & 7天Pro
2
{" "} Top 2-3
+300 积分 & 3天Pro
4
{" "} Top 4-10
+150 积分

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

{/* 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} txHash={lastTxHash || undefined} chainId={paymentConfig?.chain_id || 137} paymentTokenLabel={selectedTokenLabel} faqHref={SUBSCRIPTION_HELP_HREF} telegramGroupUrl={TELEGRAM_GROUP_URL} />
)}
{/* Telegram Bot Section */}

Telegram Bot 绑定

将下方命令发送给polyweather机器人,实现全平台气象推送与权限同步。

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

支付管理

{availableTokenList.length > 0 && (

支付币种

{availableTokenList.map((token) => { const active = token.address === (resolvedSelectedTokenAddress || token.address); return ( ); })}
)} {boundWallets.length ? (
{boundWallets.map((w) => (
{shortAddress(w.address)} {w.is_primary && ( Primary )}
Polygon Chain
))}
) : (

未绑定任何收件钱包

)}
{!walletConnectEnabled && (

未启用 WalletConnect:请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID

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