diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts index 986ba6a5..012303bd 100644 --- a/frontend/components/account/useAccountPayment.ts +++ b/frontend/components/account/useAccountPayment.ts @@ -1,60 +1,20 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import type { User } from "@supabase/supabase-js"; import { getSupabaseBrowserClient } from "@/lib/supabase/client"; -import { - getAllowedPaymentHosts, - getCurrentPaymentHost, - isPaymentHostAllowed, -} from "@/lib/payment-host"; -import { - assertExpectedPaymentReceiver, - EXPECTED_PAYMENT_RECEIVER_ADDRESS, -} from "@/lib/payment-receiver"; -import { trackAppEvent } from "@/lib/app-analytics"; import type { AuthMeResponse, BoundWallet, - ConnectBindOptions, - CreatedIntent, - Eip6963ProviderDetail, - EvmProvider, - InjectedProviderOption, - IntentStatusResponse, PaymentConfig, - PaymentTokenOption, - PaymentRecoveryState, ProviderMode, - ProviderSelection, - TelegramPricing, + InjectedProviderOption, } from "./types"; -import { - PAYMENT_RECOVERY_STORAGE_KEY, - PAYMENT_RECOVERY_TTL_MS, - WALLETCONNECT_POLYGON_RPC_URL, - WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, -} from "./constants"; -import { clearStoredPaymentRecovery, shortAddress } from "./formatters"; -import { - buildAllowanceCalldata, - buildApproveCalldata, - buildBalanceOfCalldata, - formatTokenUnits, - normalizePaymentError, - requestWalletWithTimeout, -} from "./payment-utils"; import { usePaymentState } from "./usePaymentState"; -import { - eip6963Providers, - getEvmProvider, - getEvmWalletLabel, - getWalletConnectProvider, - isWalletConnectResetError, - listInjectedProviders, - resetWalletConnectProvider, -} from "./wallet"; +import { useWalletBind } from "./useWalletBind"; +import { usePaymentFlow } from "./usePaymentFlow"; +import { useBilling } from "./useBilling"; // ============================================================ export interface UseAccountPaymentParams { @@ -121,95 +81,9 @@ export function useAccountPayment(params: UseAccountPaymentParams) { clearPaymentState, } = usePaymentState(); - // ── Additional payment/wallet state ────────────────────── - 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 [injectedProviderOptions, setInjectedProviderOptions] = useState< - InjectedProviderOption[] - >([]); - const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = - useState(""); - const [reconcileBusy, setReconcileBusy] = useState(false); - const [telegramBindUrl, setTelegramBindUrl] = useState(""); - // ── Derived values ────────────────────────────────────── const authUserId = backend?.user_id || user?.id || ""; const authIsAuthenticated = Boolean(authUserId); - const paymentReadyForRecovery = Boolean( - paymentConfig?.enabled && paymentConfig?.configured, - ); - const hasRecentPaymentRecovery = - Boolean( - lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt, - ) && - Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS; - const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []); - const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []); - const paymentHostAllowed = useMemo( - () => isPaymentHostAllowed(currentPaymentHost), - [currentPaymentHost], - ); - - // ── EIP-6963 Provider sync ────────────────────────────── - useEffect(() => { - const syncProviders = () => { - const nextOptions = listInjectedProviders(); - setInjectedProviderOptions(nextOptions); - setSelectedInjectedProviderKey((current) => { - if (current && nextOptions.some((row) => row.key === current)) { - return current; - } - return nextOptions[0]?.key || ""; - }); - }; - - const handleAnnounce = (event: Event) => { - const customEvent = event as CustomEvent; - const detail = customEvent.detail; - if ( - !detail?.provider || - typeof detail.provider.request !== "function" - ) { - return; - } - const uuid = String(detail.info?.uuid || "").trim(); - const fallbackKey = `${String(detail.info?.rdns || "wallet").toLowerCase()}:${String( - detail.info?.name || "wallet", - ).toLowerCase()}`; - eip6963Providers.set(uuid || fallbackKey, detail); - syncProviders(); - }; - - syncProviders(); - if (typeof window === "undefined") return; - window.addEventListener( - "eip6963:announceProvider", - handleAnnounce as EventListener, - ); - window.dispatchEvent(new Event("eip6963:requestProvider")); - window.addEventListener( - "ethereum#initialized", - syncProviders as EventListener, - { once: false }, - ); - return () => { - window.removeEventListener( - "eip6963:announceProvider", - handleAnnounce as EventListener, - ); - window.removeEventListener( - "ethereum#initialized", - syncProviders as EventListener, - ); - }; - }, []); // ── getValidAccessToken ────────────────────────────────── const getValidAccessToken = useCallback(async (): Promise => { @@ -220,37 +94,21 @@ export function useAccountPayment(params: UseAccountPaymentParams) { : "Supabase 未配置,无法获取登录凭证。", ); const client = getSupabaseBrowserClient(); - const { - data: { session: cached }, - } = await client.auth.getSession(); + const { data: { session: cached } } = await client.auth.getSession(); const cachedToken = String(cached?.access_token || "").trim(); const expiresAtSec = Number(cached?.expires_at || 0); const nowSec = Math.floor(Date.now() / 1000); const refreshLeadSec = 90; - if ( - cachedToken && - Number.isFinite(expiresAtSec) && - expiresAtSec > nowSec + refreshLeadSec - ) { + if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec + refreshLeadSec) { return cachedToken; } - if ( - cachedToken && - (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0) - ) { + if (cachedToken && (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0)) { return cachedToken; } - const { - data: { session: refreshed }, - error, - } = await client.auth.refreshSession(); + const { data: { session: refreshed }, error } = await client.auth.refreshSession(); const refreshedToken = String(refreshed?.access_token || "").trim(); if (refreshedToken) return refreshedToken; - if ( - cachedToken && - Number.isFinite(expiresAtSec) && - expiresAtSec > nowSec - ) { + if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec) { return cachedToken; } throw new Error( @@ -266,10 +124,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { // ── buildAuthedHeaders ────────────────────────────────── const buildAuthedHeaders = useCallback( - async ( - withJson = false, - requireAuth = false, - ): Promise> => { + async (withJson = false, requireAuth = false): Promise> => { const headers: Record = {}; if (withJson) headers["Content-Type"] = "application/json"; if (!supabaseReady) return headers; @@ -279,13 +134,9 @@ export function useAccountPayment(params: UseAccountPaymentParams) { } catch (error) { if (requireAuth) throw error; try { - const { - data: { session }, - } = await getSupabaseBrowserClient().auth.getSession(); + const { data: { session } } = await getSupabaseBrowserClient().auth.getSession(); const fallbackToken = String(session?.access_token || "").trim(); - if (fallbackToken) { - headers.Authorization = `Bearer ${fallbackToken}`; - } + if (fallbackToken) headers.Authorization = `Bearer ${fallbackToken}`; } catch { // Non-authenticated page load — silently skip. } @@ -295,73 +146,54 @@ export function useAccountPayment(params: UseAccountPaymentParams) { [supabaseReady, getValidAccessToken], ); - // ── resolvePaymentProvider ────────────────────────────── - const resolvePaymentProvider = useCallback( - async ( - mode: ProviderMode = "auto", - preferredInjectedKey = "", - ): Promise => { - const targetChainId = Number(paymentConfig?.chain_id || 137); - if (mode !== "walletconnect") { - const injectedOptions = listInjectedProviders(); - const injected = - injectedOptions.find((row) => row.key === preferredInjectedKey) - ?.provider || getEvmProvider(); - const label = - injectedOptions.find((row) => row.key === preferredInjectedKey) - ?.label || getEvmWalletLabel(injected); - if (injected) { - return { provider: injected, label, mode: "auto" }; - } - } - if (!walletConnectEnabled) { - throw new Error( - "未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。", - ); - } - const wcProvider = await getWalletConnectProvider( - targetChainId, - WALLETCONNECT_POLYGON_RPC_URL, + // ── loadSnapshot ────────────────────────────────────────── + const loadSnapshot = useCallback(async () => { + setErrorText(""); + const attempt = async (retry: boolean): Promise => { + 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 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", - }; - } + const [userResult, backendResult] = await Promise.all([userPromise, backendPromise]); + setUser(userResult.data?.user ?? null); + if (!backendResult.ok) { + if (retry && backendResult.status === 401) { + await new Promise((r) => setTimeout(r, 1200)); + return attempt(false); } + const raw = (await backendResult.text()).slice(0, 260); + throw new Error(copy.httpError.replace("{status}", String(backendResult.status)).replace("{raw}", raw)); } - return { - provider: wcProvider, - label: "WalletConnect", - mode: "walletconnect", - }; - }, - [paymentConfig?.chain_id, walletConnectEnabled], - ); + const backendJson = (await backendResult.json()) as AuthMeResponse; + setBackend(backendJson); + setUpdatedAt(new Date().toISOString()); + }; + try { await attempt(true); } + catch (error) { setErrorText(String(error)); } + }, [buildAuthedHeaders, supabaseReady, copy.httpError]); + + // ── Shared state for sub-hooks ─────────────────────────── + // These state variables are managed in the master hook and passed + // to the relevant sub-hooks. loadPaymentSnapshot crosses domains. + 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 [injectedProviderOptions, setInjectedProviderOptions] = useState([]); + const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState(""); + + // ── Chain ID derived from payment config ──────────────── + const chainId = paymentConfig?.chain_id ?? 137; // ── loadPaymentSnapshot ────────────────────────────────── - const loadPaymentSnapshot = useCallback(async () => { + // Defined in master because it sets state across multiple sub-hook domains. + const loadPaymentSnapshotImpl = useCallback(async () => { if (!backend?.authenticated) { setPaymentConfig(null); setBoundWallets([]); @@ -370,12 +202,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) { 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 }), - ), + 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; @@ -384,67 +212,36 @@ export function useAccountPayment(params: UseAccountPaymentParams) { 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"), - ) + ? 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 || - "", + configJson.token_address || "", ).toLowerCase(); if (defaultTokenAddress) { - setSelectedTokenAddress( - (prev) => prev || defaultTokenAddress, - ); + setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress); } } if (walletsRes.ok) { - const walletsJson = (await walletsRes.json()) as { - wallets?: BoundWallet[]; - }; - const wallets = ( - Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [] - ) + const walletsJson = (await walletsRes.json()) as { wallets?: BoundWallet[] }; + const wallets = (Array.isArray(walletsJson.wallets) ? walletsJson.wallets : []) .filter((row) => { const status = String(row?.status || "active").toLowerCase(); const address = String(row?.address || ""); return status === "active" && address.startsWith("0x"); }) - .map((row) => ({ - ...row, - address: String(row.address || "").toLowerCase(), - })); + .map((row) => ({ ...row, address: String(row.address || "").toLowerCase() })); setBoundWallets(wallets); if (wallets.length) { - const currentSelected = String( - selectedWallet || "", - ).toLowerCase(); - const hasCurrent = wallets.some( - (row) => - String(row.address || "").toLowerCase() === currentSelected, - ); - const fallback = - wallets.find((row) => Boolean(row.is_primary))?.address || - wallets[0].address; - if (!currentSelected || !hasCurrent) { - setSelectedWallet(fallback); - } - const currentWalletAddress = String( - walletAddress || "", - ).toLowerCase(); - const hasWalletAddress = wallets.some( - (row) => - String(row.address || "").toLowerCase() === - currentWalletAddress, - ); - if (!currentWalletAddress || !hasWalletAddress) { - setWalletAddress(fallback); - } + const currentSelected = String(selectedWallet || "").toLowerCase(); + const hasCurrent = wallets.some((row) => String(row.address || "").toLowerCase() === currentSelected); + const fallback = wallets.find((row) => Boolean(row.is_primary))?.address || wallets[0].address; + if (!currentSelected || !hasCurrent) setSelectedWallet(fallback); + const currentWalletAddress = String(walletAddress || "").toLowerCase(); + const hasWalletAddress = wallets.some((row) => String(row.address || "").toLowerCase() === currentWalletAddress); + if (!currentWalletAddress || !hasWalletAddress) setWalletAddress(fallback); } else { setSelectedWallet(""); setWalletAddress(""); @@ -461,1854 +258,133 @@ export function useAccountPayment(params: UseAccountPaymentParams) { walletAddress, ]); - // ── fetchLatestPaymentConfig ──────────────────────────── - const fetchLatestPaymentConfig = useCallback( - async ( - authHeaders?: Record, - syncState = true, - ): Promise => { - const headers = authHeaders || (await buildAuthedHeaders(false)); - const configRes = await fetch("/api/payments/config", { - cache: "no-store", - headers, - }); - if (!configRes.ok) { - const raw = (await configRes.text()).slice(0, 350); - throw new Error(copy.loadConfigFailed.replace("{raw}", raw)); - } - const configJson = (await configRes.json()) as PaymentConfig; - if (syncState) { - 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, - ); - } - } - return configJson; - }, - [buildAuthedHeaders, selectedPlanCode], + // ── Selected plan (derived, shared across sub-hooks) ─── + const monthlyPlanList = (paymentConfig?.plans || []).filter( + (p) => String(p.plan_code || "").trim().toLowerCase() === "pro_monthly", ); + const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : (paymentConfig?.plans || []); + const selectedPlan = effectivePlanList.find((p) => p.plan_code === selectedPlanCode) || effectivePlanList[0]; - // ── loadSnapshot ────────────────────────────────────────── - const loadSnapshot = useCallback(async () => { - setErrorText(""); - const attempt = async (retry: boolean): Promise => { - 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) { - if (retry && backendResult.status === 401) { - await new Promise((r) => setTimeout(r, 1200)); - return attempt(false); - } - const raw = (await backendResult.text()).slice(0, 260); - throw new Error( - copy.httpError - .replace("{status}", String(backendResult.status)) - .replace("{raw}", raw), - ); - } - const backendJson = - (await backendResult.json()) as AuthMeResponse; - setBackend(backendJson); - setUpdatedAt(new Date().toISOString()); - }; - try { - await attempt(true); - } catch (error) { - setErrorText(String(error)); - } - }, [buildAuthedHeaders, supabaseReady, copy.httpError]); + // ── useWalletBind ────────────────────────────────────── + const walletBind = useWalletBind({ + isEn, + walletConnectEnabled, + copy, + chainId, + boundWallets, + setBoundWallets, + walletAddress, + setWalletAddress, + selectedWallet, + setSelectedWallet, + providerMode, + setProviderMode, + injectedProviderOptions, + setInjectedProviderOptions, + selectedInjectedProviderKey, + setSelectedInjectedProviderKey, + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setShowOverlay, + clearPaymentMessages, + authIsAuthenticated, + getValidAccessToken, + buildAuthedHeaders, + loadSnapshot, + loadPaymentSnapshot: loadPaymentSnapshotImpl, + }); - // ── loadPaymentSnapshot effect ──────────────────────────── - useEffect(() => { - void loadPaymentSnapshot(); - }, [loadPaymentSnapshot]); + // Ref to break circular dependency: usePaymentFlow needs connectAndBindWallet from useWalletBind + const connectAndBindWalletRef = useRef(walletBind.connectAndBindWallet); + connectAndBindWalletRef.current = walletBind.connectAndBindWallet; - // ── Recovery save effect ──────────────────────────────── - useEffect(() => { - if (typeof window === "undefined") return; - if ( - !( - lastIntentId && - lastTxHash && - authUserId && - lastPaymentStartedAt - ) - ) { - clearStoredPaymentRecovery(); - return; - } - const payload: PaymentRecoveryState = { - intentId: lastIntentId, - txHash: lastTxHash, - userId: authUserId, - createdAt: lastPaymentStartedAt, - }; - window.sessionStorage.setItem( - PAYMENT_RECOVERY_STORAGE_KEY, - JSON.stringify(payload), - ); - }, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]); - - // ── Recovery restore effect ────────────────────────────── - useEffect(() => { - if (typeof window === "undefined") return; - if (!authUserId) return; - if (lastIntentId && lastTxHash && lastPaymentStartedAt) return; - const raw = - window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY); - if (!raw) return; - try { - const parsed = JSON.parse(raw) as PaymentRecoveryState; - const userId = String(parsed?.userId || "").trim(); - const intentId = String(parsed?.intentId || "").trim(); - const txHash = String(parsed?.txHash || "") - .trim() - .toLowerCase(); - const createdAt = Number(parsed?.createdAt || 0); - const expired = - !createdAt || - Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS; - if ( - expired || - !intentId || - !txHash || - !userId || - userId !== authUserId - ) { - clearStoredPaymentRecovery(); - return; - } - setLastIntentId(intentId); - setLastTxHash(txHash); - setLastPaymentStartedAt(createdAt); - } catch { - clearStoredPaymentRecovery(); - } - }, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]); - - // ── Subscription cleanup effect ────────────────────────── - useEffect(() => { - if (!backend?.subscription_active) return; - clearPaymentState(); - clearStoredPaymentRecovery(); - }, [backend?.subscription_active]); - - // ── Derived payment values ────────────────────────────── - 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 paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS; - - const paymentWalletLabel = String( - selectedWallet || - walletAddress || - boundWallets.find((row) => row.is_primary)?.address || - boundWallets[0]?.address || - "", - ).toLowerCase(); - - const hasPayingWallet = Boolean( - String( - selectedWallet || walletAddress || boundWallets[0]?.address || "", - ).trim(), - ); - - // ── Points computation ────────────────────────────────── - 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 totalPoints = Number.isFinite(pointsRaw) - ? Math.max(0, pointsRaw) - : 0; - - // ── Billing ────────────────────────────────────────────── - const billing = useMemo(() => { - const parsedPlanAmount = Number( - backend?.telegram_pricing?.amount_usdc ?? - selectedPlan?.amount_usdc ?? - 10, - ); - const planAmount = - Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 - ? parsedPlanAmount - : 10; - - 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, - backend?.telegram_pricing?.amount_usdc, - selectedPlan?.amount_usdc, - totalPoints, + // ── useBilling ────────────────────────────────────────── + const billing = useBilling({ + isEn, + copy, + backend, + supabaseReady, + paymentConfig, + authUserId, + authIsAuthenticated, usePoints, - ]); - - // ── reconcileLatestPayment ────────────────────────────── - const reconcileLatestPayment = useCallback(async () => { - if (!authIsAuthenticated || reconcileBusy) return false; - setReconcileBusy(true); - try { - const headers = await buildAuthedHeaders(true, true); - const res = await fetch("/api/payments/reconcile-latest", { - method: "POST", - headers, - }); - if (!res.ok) return false; - const json = (await res.json()) as { - ok?: boolean; - action?: string; - subscription?: { plan_code?: string | null } | null; - }; - if (json.ok) { - setPaymentInfo(copy.walletRecoveryDone); - setPaymentError(""); - await loadSnapshot(); - await loadPaymentSnapshot(); - return true; - } - return false; - } catch { - return false; - } finally { - setReconcileBusy(false); - } - }, [ - authIsAuthenticated, + setUsePoints, + lastIntentId, + lastTxHash, + lastPaymentStartedAt, + setLastIntentId, + setLastTxHash, + setLastPaymentStartedAt, + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setTelegramBindOpening, + clearPaymentState, + selectedPlan, + getValidAccessToken, buildAuthedHeaders, - copy.walletRecoveryDone, - loadPaymentSnapshot, loadSnapshot, - reconcileBusy, - ]); + loadPaymentSnapshot: loadPaymentSnapshotImpl, + user, + }); - // ── handleSubmit409 ──────────────────────────────────── - const handleSubmit409 = useCallback( - async (intentId: string, txHashNorm: string, raw: string) => { - const lowerRaw = raw.toLowerCase(); - if ( - lowerRaw.includes("已支付") || - lowerRaw.includes("already confirmed") || - lowerRaw.includes("already paid") - ) { - const ok = await reconcileLatestPayment(); - if (ok) return; - setPaymentInfo(copy.orderAlreadyPaid); - await loadSnapshot(); - await loadPaymentSnapshot(); - return; - } - if (lowerRaw.includes("expired")) { - throw new Error(copy.orderExpired); - } - try { - const headers = await buildAuthedHeaders(true, false); - const intentRes = await fetch( - `/api/payments/intents/${intentId}`, - { - headers, - cache: "no-store", - }, - ); - if (intentRes.ok) { - const intentJson = (await intentRes.json()) as { - intent?: { status?: string; tx_hash?: string }; - }; - const status = intentJson.intent?.status; - if (status === "confirmed") { - await reconcileLatestPayment(); - const txHash = - intentJson.intent?.tx_hash || txHashNorm; - setPaymentInfo( - `支付已确认,交易: ${shortAddress(txHash)}`, - ); - setPaymentError(""); - await loadSnapshot(); - await loadPaymentSnapshot(); - return; - } - if (status === "expired") { - throw new Error(copy.orderExpired); - } - } - } catch (e) { - if (e instanceof Error && e.message !== raw) throw e; - } - throw new Error( - copy.submitTxFailed.replace("{raw}", raw), - ); - }, - [ - buildAuthedHeaders, - copy.orderAlreadyPaid, - copy.orderExpired, - copy.submitTxFailed, - loadPaymentSnapshot, - loadSnapshot, - reconcileLatestPayment, - ], - ); + // Ref to break circular dependency: usePaymentFlow needs handleSubmit409 from useBilling + const handleSubmit409Ref = useRef(billing.handleSubmit409); + handleSubmit409Ref.current = billing.handleSubmit409; - // ── Auto-reconcile effect ────────────────────────────── - useEffect(() => { - if (!authIsAuthenticated) return; - if (backend?.subscription_active) return; - if (!paymentReadyForRecovery) return; - if (!hasRecentPaymentRecovery) return; - let cancelled = false; - const run = async () => { - setPaymentInfo(copy.walletRecoveryBusy); - const repaired = await reconcileLatestPayment(); - if (cancelled) return; - if (!repaired && !backend?.subscription_active) { - setPaymentInfo(""); - setPaymentError(copy.walletRecoveryFailed); - } - }; - void run(); - return () => { - cancelled = true; - }; - }, [ - backend?.subscription_active, - authIsAuthenticated, - copy.walletRecoveryBusy, - copy.walletRecoveryFailed, - hasRecentPaymentRecovery, - paymentReadyForRecovery, - reconcileLatestPayment, - ]); - - // ── Low-level helpers ────────────────────────────────── - const waitForReceipt = async ( - txHash: string, - provider?: EvmProvider, - timeoutMs = 120000, - pollMs = 3000, - ) => { - const eth = provider || getEvmProvider(); - if (!eth) throw new Error(copy.noWalletProvider); - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - const receipt = - await requestWalletWithTimeout<{ - status?: string; - } | null>( - eth, - { - method: "eth_getTransactionReceipt", - params: [txHash], - }, - "查询授权交易确认", - 15_000, - ); - if (receipt && receipt.status) { - if (receipt.status === "0x1") return receipt; - throw new Error( - copy.txReverted.replace("{txHash}", txHash), - ); - } - await new Promise((resolve) => setTimeout(resolve, pollMs)); - } - throw new Error( - copy.txConfirmTimeout.replace("{txHash}", txHash), - ); - }; - - const signBindMessage = async ( - eth: EvmProvider, - address: string, - message: string, - ): Promise => { - try { - return (await eth.request({ - method: "personal_sign", - params: [message, address], - })) as string; - } catch { - return (await eth.request({ - method: "personal_sign", - params: [address, message], - })) as string; - } - }; - - const ensureTargetChain = async ( - eth: EvmProvider, - targetChainId: number, - ): Promise => { - const currentChainIdHex = String( - (await requestWalletWithTimeout( - eth, - { method: "eth_chainId" }, - copy.chainReadError, - )) || "", - ); - const targetChainHex = `0x${targetChainId.toString(16)}`; - if ( - currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase() - ) - return; - try { - await requestWalletWithTimeout( - eth, - { - method: "wallet_switchEthereumChain", - params: [{ chainId: targetChainHex }], - }, - copy.chainSwitchError, - ); - } catch (err: any) { - const code = Number(err?.code); - - if (code === 4902 || targetChainId === 137) { - try { - await requestWalletWithTimeout( - eth, - { - 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"], - }, - ], - }, - copy.chainAddPolygon, - ); - return; - } catch (addErr: any) { - err = addErr; - } - } - throw new Error( - `${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`, - ); - } - }; - - // ── pollIntentUntilConfirmed ──────────────────────────── - 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( - copy.queryIntentFailed.replace("{raw}", 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( - copy.paymentConfirmed.replace( - "{txHash}", - shortAddress(txHash), - ), - ); - trackAppEvent("checkout_succeeded", { - entry: "account_center", - plan_code: selectedPlan?.plan_code || "pro_monthly", - intent_id: intentId, - tx_hash: txHash || null, - }); - await loadSnapshot(); - await loadPaymentSnapshot(); - return; - } - if ( - status === "failed" || - status === "cancelled" || - status === "expired" - ) { - throw new Error( - copy.paymentStatus.replace("{status}", status), - ); - } - setPaymentInfo( - copy.txSubmitted - .replace("{txHash}", shortTx) - .replace("{status}", status || "submitted"), - ); - await new Promise((resolve) => - setTimeout(resolve, pollMs), - ); - } - throw new Error(copy.paymentPendingTimeout); - }, - [ - loadPaymentSnapshot, - loadSnapshot, - selectedPlan?.plan_code, - ], - ); - - // ── connectAndBindWallet ──────────────────────────────── - const connectAndBindWallet = async ( - mode: ProviderMode = "auto", - options: ConnectBindOptions = {}, - ): Promise => { - clearPaymentMessages(); - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || - "polyweather-pro.vercel.app", - ), - ); - return false; - } - if (!authIsAuthenticated) { - setPaymentError(copy.loginBeforeBind); - return false; - } - - setPaymentBusy(true); - try { - const providerSelection = await resolvePaymentProvider( - mode, - selectedInjectedProviderKey, - ); - const eth = providerSelection.provider; - const walletLabel = providerSelection.label; - const binanceBindHint = walletLabel - .toLowerCase() - .includes("binance") - ? copy.binanceBindHint - : ""; - - 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 requestWalletWithTimeout( - eth, - { method: "eth_requestAccounts" }, - "连接绑定钱包", - ); - const address = String(accounts?.[0] || "").toLowerCase(); - if (!address) - throw new Error( - isEn - ? "Wallet account is empty." - : "钱包账户为空", - ); - - const existingWallet = boundWallets.find( - (w) => - String(w.address || "").toLowerCase() === address, - ); - if (existingWallet) { - setWalletAddress(address); - setSelectedWallet(address); - setPaymentInfo( - `${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`, - ); - await Promise.all([ - loadSnapshot(), - loadPaymentSnapshot(), - ]); - 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( - copy.challengeFailed.replace("{raw}", 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(copy.challengeInvalid); - - 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( - copy.verifyFailedRaw.replace("{raw}", raw), - ); - } - - setPaymentInfo( - `${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`, - ); - setProviderMode(providerSelection.mode); - if (options.openOverlayAfterBind) setShowOverlay(true); - await Promise.all([ - loadSnapshot(), - loadPaymentSnapshot(), - ]); - return true; - } catch (error) { - setPaymentInfo(""); - setPaymentError( - normalizePaymentError(error).message, - ); - return false; - } finally { - setPaymentBusy(false); - } - }; - - // ── handleUnbindWallet ────────────────────────────────── - const handleUnbindWallet = async (address: string) => { - const target = String(address || "").toLowerCase(); - if (!target) return; - if (!authIsAuthenticated) { - setPaymentError(copy.loginBeforeBind); - return; - } - const confirmed = window.confirm( - copy.unbindConfirm.replace( - "{address}", - shortAddress(target), - ), - ); - if (!confirmed) return; - - setPaymentBusy(true); - clearPaymentMessages(); - try { - const headers = await buildAuthedHeaders(true, false); - 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, - ); - if (detail.trim().startsWith("{")) { - try { - const nested = JSON.parse(detail); - detail = String( - nested?.detail || - nested?.error || - detail, - ); - } catch { - // ignore nested parse failure - } - } - } catch { - // ignore - } - throw new Error( - detail || - copy.httpError - .replace("{status}", String(res.status)) - .replace("{raw}", ""), - ); - } - - let data: Record = {}; - try { - data = raw - ? (JSON.parse(raw) as Record) - : {}; - } catch { - data = {}; - } - const newPrimary = String( - data?.new_primary || "", - ).toLowerCase(); - const selectedWalletNorm = String( - selectedWallet || "", - ).toLowerCase(); - const walletAddressNorm = String( - walletAddress || "", - ).toLowerCase(); - if (selectedWalletNorm === target) { - setSelectedWallet(newPrimary || ""); - } - if (walletAddressNorm === target) { - setWalletAddress(newPrimary || ""); - } - setBoundWallets((prev) => - prev.filter( - (row) => - String(row.address || "").toLowerCase() !== - String(target), - ), - ); - await loadPaymentSnapshot(); - setPaymentInfo( - newPrimary - ? copy.unbindDonePrimary.replace( - "{address}", - shortAddress(newPrimary), - ) - : copy.unbindDone, - ); - } catch (error) { - const message = normalizePaymentError(error) - .message; - const lower = String(message || "").toLowerCase(); - if ( - lower.includes("unauthorized") || - lower.includes("session required") || - lower.includes("401") - ) { - setPaymentError( - `${copy.unbindFailed}: ${copy.authExpired}`, - ); - return; - } - setPaymentError( - `${copy.unbindFailed}: ${message}`, - ); - } finally { - setPaymentBusy(false); - } - }; - - // ── createIntentAndPay ────────────────────────────────── - const createIntentAndPay = async () => { - clearPaymentMessages(); - clearPaymentState(); - clearStoredPaymentRecovery(); - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || - "polyweather-pro.vercel.app", - ), - ); - return; - } - if (!authIsAuthenticated) { - setPaymentError(copy.loginBeforePay); - return; - } - if (!paymentConfig?.configured) { - setPaymentError(copy.payNotReady); - return; - } - - const fallbackWallet = String( - selectedWallet || - walletAddress || - boundWallets[0]?.address || - "", - ).toLowerCase(); - if (!fallbackWallet) { - setPaymentError(copy.bindFirstBeforePay); - return; - } - - setPaymentBusy(true); - let approvedInThisRun = false; - try { - const providerSelection = await resolvePaymentProvider( - providerMode, - selectedInjectedProviderKey, - ); - const eth = providerSelection.provider; - const activeAccounts = - await requestWalletWithTimeout( - eth, - { method: "eth_requestAccounts" }, - "连接付款钱包", - ); - const activeAddress = String( - activeAccounts?.[0] || "", - ).toLowerCase(); - if (!activeAddress) - throw new Error( - isEn - ? "Wallet account is empty." - : "钱包账户为空", - ); - - 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); - - 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 latestConfig = await fetchLatestPaymentConfig( - authHeaders, - true, - ); - if (!latestConfig?.enabled || !latestConfig?.configured) { - throw new Error(copy.payNotReady); - } - const expectedReceiver = String( - latestConfig.receiver_contract || "", - ).toLowerCase(); - assertExpectedPaymentReceiver( - expectedReceiver, - "payment receiver contract", - ); - if ( - paymentConfig?.receiver_contract && - String(paymentConfig.receiver_contract).toLowerCase() !== - expectedReceiver - ) { - setPaymentInfo( - copy.paymentConfigUpdated.replace( - "{address}", - shortAddress(expectedReceiver), - ), - ); - } else { - setPaymentInfo( - copy.currentReceiver.replace( - "{address}", - shortAddress(expectedReceiver), - ), - ); - } - - const targetChainId = Number( - latestConfig.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", - frontend_host: - currentPaymentHost || null, - account_email: backend?.email || null, - }, - }), - }, - ); - if (!createRes.ok) { - const raw = (await createRes.text()).slice(0, 350); - throw new Error( - copy.createIntentFailed.replace( - "{raw}", - 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(copy.intentPayloadInvalid); - trackAppEvent("checkout_started", { - entry: "account_center", - plan_code: - selectedPlan?.plan_code || "pro_monthly", - intent_id: intentId, - use_points: billing.canRedeem && usePoints, - pay_amount_usd: billing.payAmount, - }); - const intentReceiver = String( - txPayload.to || "", - ).toLowerCase(); - if (intentReceiver !== expectedReceiver) { - throw new Error( - `payment receiver changed: expected ${expectedReceiver}, got ${intentReceiver}. 请刷新页面后重试。`, - ); - } - 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(copy.intentTokenInvalid); - const tokenSymbol = String( - txPayload.token_symbol || - selectedPaymentToken?.symbol || - selectedTokenLabel || - "USDC", - ); - const tokenDecimals = Number( - txPayload.token_decimals ?? - selectedPaymentToken?.decimals ?? - latestConfig?.token_decimals ?? - 6, - ); - - const balanceHex = - await requestWalletWithTimeout( - eth, - { - method: "eth_call", - params: [ - { - to: tokenAddress, - data: buildBalanceOfCalldata( - payingWallet, - ), - }, - "latest", - ], - }, - `读取 ${tokenSymbol} 余额`, - ); - 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 requestWalletWithTimeout( - eth, - { - method: "eth_call", - params: [ - { - to: tokenAddress, - data: buildAllowanceCalldata( - payingWallet, - txPayload.to, - ), - }, - "latest", - ], - }, - `读取 ${tokenSymbol} 授权额度`, - ); - const allowance = BigInt( - String(allowanceHex || "0x0"), - ); - - if (allowance < amountUnits) { - setPaymentInfo( - copy.approvalDetected.replace( - "{symbol}", - tokenSymbol, - ), - ); - const approveParams: Record = { - from: payingWallet, - to: tokenAddress, - data: buildApproveCalldata( - txPayload.to, - amountUnits, - ), - }; - const approveHash = - await requestWalletWithTimeout( - eth, - { - method: "eth_sendTransaction", - params: [approveParams], - }, - `发起 ${tokenSymbol} 授权`, - WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, - ); - await waitForReceipt( - String(approveHash || ""), - eth, - ); - approvedInThisRun = true; - setPaymentInfo( - copy.approvalDone.replace( - "{symbol}", - tokenSymbol, - ), - ); - } else { - setPaymentInfo(copy.approvalSufficient); - } - - const payParams: Record = { - from: payingWallet, - to: txPayload.to, - data: txPayload.data, - }; - if ( - txPayload.value && - txPayload.value !== "0x0" && - txPayload.value !== "0" - ) { - payParams.value = txPayload.value; - } - - const txHash = - await requestWalletWithTimeout( - eth, - { - method: "eth_sendTransaction", - params: [payParams], - }, - "发起支付交易", - WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, - ); - const txHashNorm = String( - txHash || "", - ).toLowerCase(); - setLastTxHash(txHashNorm); - setLastPaymentStartedAt(Date.now()); - - 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, - ); - if (submitRes.status === 409) { - await handleSubmit409( - intentId, - txHashNorm, - raw, - ); - return; - } - throw new Error( - copy.submitTxFailed.replace( - "{raw}", - 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( - copy.confirmFailed.replace("{raw}", raw), - ); - } - - setPaymentInfo( - copy.paymentConfirmed.replace( - "{txHash}", - shortAddress(txHashNorm), - ), - ); - trackAppEvent("checkout_succeeded", { - entry: "account_center", - plan_code: - selectedPlan?.plan_code || "pro_monthly", - intent_id: intentId, - tx_hash: 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); - } - }; - - // ── createManualPaymentIntent ──────────────────────────── - const createManualPaymentIntent = async () => { - clearPaymentMessages(); - setManualPayment(null); - setManualTxHash(""); - setLastIntentId(""); - setLastTxHash(""); - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || - "polyweather-pro.vercel.app", - ), - ); - return; - } - if (!authIsAuthenticated) { - setPaymentError(copy.loginBeforePay); - return; - } - if (!paymentConfig?.configured) { - setPaymentError(copy.payNotReady); - return; - } - - setPaymentBusy(true); - try { - const authHeaders = await buildAuthedHeaders( - true, - false, - ); - const createRes = await fetch( - "/api/payments/intents", - { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ - plan_code: - selectedPlan?.plan_code || "pro_monthly", - payment_mode: "direct", - token_address: - resolvedSelectedTokenAddress || - undefined, - use_points: billing.canRedeem && usePoints, - points_to_consume: - billing.canRedeem && usePoints - ? billing.pointsUsed - : 0, - metadata: { - source: - "account_center_manual_transfer", - frontend_host: - currentPaymentHost || null, - account_email: backend?.email || null, - }, - }), - }, - ); - if (!createRes.ok) { - const raw = (await createRes.text()).slice( - 0, - 350, - ); - throw new Error( - copy.createManualIntentFailed.replace( - "{raw}", - raw, - ), - ); - } - const created = - (await createRes.json()) as CreatedIntent; - const direct = created.direct_payment; - const intentId = String( - created.intent?.intent_id || - direct?.intent_id || - "", - ); - if ( - !intentId || - !direct?.receiver_address || - !direct?.amount_usdc - ) { - throw new Error(copy.manualPaymentInvalid); - } - assertExpectedPaymentReceiver( - direct.receiver_address, - "manual payment receiver", - ); - setLastIntentId(intentId); - setManualPayment(direct); - setPaymentMethodTab("manual"); - setShowOverlay(false); - setPaymentInfo( - `手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel} 到 ${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`, - ); - trackAppEvent("checkout_started", { - entry: "account_center_manual_transfer", - plan_code: - selectedPlan?.plan_code || "pro_monthly", - intent_id: intentId, - payment_mode: "direct", - use_points: billing.canRedeem && usePoints, - pay_amount_usd: billing.payAmount, - }); - } catch (error) { - setPaymentError( - normalizePaymentError(error).message, - ); - } finally { - setPaymentBusy(false); - } - }; - - // ── submitManualPaymentTx ────────────────────────────── - const submitManualPaymentTx = async () => { - const txHashNorm = String(manualTxHash || "") - .trim() - .toLowerCase(); - const intentId = String( - lastIntentId || manualPayment?.intent_id || "", - ).trim(); - if (!intentId || !manualPayment) { - setPaymentError(copy.manualOrderRequired); - return; - } - if ( - !txHashNorm.startsWith("0x") || - txHashNorm.length !== 66 - ) { - setPaymentError(copy.txHashRequired); - return; - } - setPaymentBusy(true); - setPaymentError(""); - try { - const authHeaders = await buildAuthedHeaders( - true, - false, - ); - const submitRes = await fetch( - `/api/payments/intents/${intentId}/submit`, - { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ - tx_hash: txHashNorm, - }), - }, - ); - if (!submitRes.ok) { - const raw = (await submitRes.text()).slice( - 0, - 350, - ); - if (submitRes.status === 409) { - await handleSubmit409( - intentId, - txHashNorm, - raw, - ); - return; - } - throw new Error( - copy.submitTxFailed.replace( - "{raw}", - 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 === 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( - copy.confirmFailed.replace( - "{raw}", - raw, - ), - ); - } - setLastTxHash(txHashNorm); - setPaymentInfo( - copy.paymentConfirmed.replace( - "{txHash}", - shortAddress(txHashNorm), - ), - ); - setManualPayment(null); - setManualTxHash(""); - setTxValidation({ - loading: false, - checked: false, - }); - trackAppEvent("checkout_succeeded", { - entry: - "account_center_manual_transfer", - plan_code: - selectedPlan?.plan_code || - "pro_monthly", - intent_id: intentId, - tx_hash: txHashNorm, - }); - await loadSnapshot(); - await loadPaymentSnapshot(); - } catch (error) { - setPaymentError( - normalizePaymentError(error).message, - ); - } finally { - setPaymentBusy(false); - } - }; - - // ── validateTxHash ──────────────────────────────────── - const validateTxHash = useCallback( - async (intentId: string, hash: string) => { - const hashNorm = String(hash || "") - .trim() - .toLowerCase(); - if ( - !hashNorm.startsWith("0x") || - hashNorm.length !== 66 - ) { - setTxValidation({ - loading: false, - checked: false, - }); - return; - } - setTxValidation({ - loading: true, - checked: false, - }); - try { - const headers = await buildAuthedHeaders( - true, - false, - ); - const res = await fetch( - `/api/payments/intents/${intentId}/validate`, - { - method: "POST", - headers, - body: JSON.stringify({ - tx_hash: hashNorm, - }), - }, - ); - const json = (await res.json()) as { - valid?: boolean; - reason?: string; - detail?: string; - checks?: Record; - }; - setTxValidation({ - loading: false, - checked: true, - valid: Boolean(json.valid), - reason: json.reason, - detail: json.detail, - checks: json.checks, - }); - } catch { - setTxValidation({ - loading: false, - checked: false, - }); - } - }, - [buildAuthedHeaders], - ); - - // ── handleOverlayCheckout ────────────────────────────── - const handleOverlayCheckout = async () => { - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || - "polyweather-pro.vercel.app", - ), - ); - return; - } - if (!authIsAuthenticated) { - setPaymentError(copy.loginBeforePay); - return; - } - if (!hasPayingWallet) { - setPaymentInfo(copy.openBindFlow); - const bound = await connectAndBindWallet( - providerMode, - { openOverlayAfterBind: true }, - ); - if (!bound) return; - setPaymentInfo(copy.walletBoundCreatingOrder); - await createIntentAndPay(); - return; - } - await createIntentAndPay(); - }; - - // ── openTelegramBotBindLink ────────────────────────────── - const openTelegramBotBindLink = async () => { - setTelegramBindOpening(true); - setPaymentError(""); - setTelegramBindUrl(""); - const popup = window.open( - "about:blank", - "_blank", - "noopener,noreferrer", - ); - try { - const authHeaders = await buildAuthedHeaders( - true, - false, - ); - const res = await fetch( - "/api/auth/telegram/bot-bind-link", - { - method: "POST", - headers: authHeaders, - }, - ); - if (!res.ok) { - const raw = (await res.text()).slice(0, 300); - throw new Error( - raw || copy.telegramBindFailed, - ); - } - const data = (await res.json()) as { - bot_url?: string; - }; - const botUrl = String(data.bot_url || "").trim(); - if (!botUrl) - throw new Error(copy.telegramBindLinkMissing); - if (popup && !popup.closed) { - popup.location.href = botUrl; - setPaymentInfo(copy.telegramBindClickHint); - } else { - setPaymentInfo(copy.telegramPopupBlocked); - setTelegramBindUrl(botUrl); - } - } catch (error) { - if (popup && !popup.closed) popup.close(); - setPaymentError( - normalizePaymentError(error).message, - ); - } finally { - setTelegramBindOpening(false); - } - }; - - // ── bind_token URL effect ────────────────────────────── - useEffect(() => { - if (!authIsAuthenticated) return; - const params = new URLSearchParams( - window.location.search, - ); - const token = params.get("bind_token"); - if (!token) return; - const url = new URL(window.location.href); - url.searchParams.delete("bind_token"); - window.history.replaceState( - null, - "", - url.toString(), - ); - (async () => { - setPaymentError(""); - setPaymentInfo(""); - try { - const authHeaders = await buildAuthedHeaders( - true, - false, - ); - const res = await fetch( - "/api/auth/telegram/bind-by-token", - { - method: "POST", - headers: authHeaders, - body: JSON.stringify({ token }), - }, - ); - if (!res.ok) { - const raw = (await res.text()).slice( - 0, - 350, - ); - throw new Error( - copy.bindFailed.replace("{raw}", raw), - ); - } - const data = (await res.json()) as { - telegram_pricing?: TelegramPricing | null; - }; - if ( - data.telegram_pricing?.is_group_member - ) { - const amount = - data.telegram_pricing.amount_usdc || - "10"; - setPaymentInfo( - copy.telegramVerifySuccess.replace( - "{amount}", - amount, - ), - ); - } - await loadSnapshot(); - await loadPaymentSnapshot(); - } catch (error) { - setPaymentError( - normalizePaymentError(error).message, - ); - } - })(); - }, [ + // ── usePaymentFlow ────────────────────────────────────── + const paymentFlow = usePaymentFlow({ + isEn, + copy, + backend, + paymentConfig, + setPaymentConfig, + selectedPlanCode, + setSelectedPlanCode, + selectedTokenAddress, + setSelectedTokenAddress, + boundWallets, + selectedWallet, + walletAddress, + providerMode, + selectedInjectedProviderKey, + manualTxHash, + manualPayment, + lastIntentId, + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setLastIntentId, + setLastTxHash, + setLastPaymentStartedAt, + setShowOverlay, + setManualPayment, + setManualTxHash, + setTxValidation, + setPaymentMethodTab, + clearPaymentMessages, + clearPaymentState, + setSelectedWallet, + setProviderMode, + selectedPlan, + billing: billing.billing, + usePoints, + currentPaymentHost: billing.currentPaymentHost, + paymentHostAllowed: billing.paymentHostAllowed, + allowedPaymentHosts: billing.allowedPaymentHosts, authIsAuthenticated, + hasPayingWallet: walletBind.hasPayingWallet, + getValidAccessToken, buildAuthedHeaders, - loadPaymentSnapshot, loadSnapshot, - ]); + loadPaymentSnapshot: loadPaymentSnapshotImpl, + waitForReceipt: walletBind.waitForReceipt, + ensureTargetChain: walletBind.ensureTargetChain, + resolvePaymentProvider: walletBind.resolvePaymentProvider, + connectAndBindWalletRef, + handleSubmit409Ref, + }); // ========================================================== return { @@ -2320,7 +396,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { lastTxHash, lastPaymentStartedAt, telegramBindOpening, - telegramBindUrl, + telegramBindUrl: billing.telegramBindUrl, manualPayment, manualTxHash, txValidation, @@ -2351,7 +427,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) { providerMode, injectedProviderOptions, selectedInjectedProviderKey, - reconcileBusy, + reconcileBusy: billing.reconcileBusy, // Setters for shared state setSelectedTokenAddress, @@ -2362,33 +438,33 @@ export function useAccountPayment(params: UseAccountPaymentParams) { // Derived values authUserId, authIsAuthenticated, - paymentReadyForRecovery, - hasRecentPaymentRecovery, - allowedPaymentHosts, - currentPaymentHost, - paymentHostAllowed, + paymentReadyForRecovery: billing.paymentReadyForRecovery, + hasRecentPaymentRecovery: billing.hasRecentPaymentRecovery, + allowedPaymentHosts: billing.allowedPaymentHosts, + currentPaymentHost: billing.currentPaymentHost, + paymentHostAllowed: billing.paymentHostAllowed, selectedPlan, - selectedPaymentToken, - selectedTokenLabel, - availableTokenList, + selectedPaymentToken: paymentFlow.selectedPaymentToken, + selectedTokenLabel: paymentFlow.selectedTokenLabel, + availableTokenList: paymentFlow.availableTokenList, effectivePlanList, - resolvedSelectedTokenAddress, - paymentReceiverAddress, - paymentWalletLabel, - hasPayingWallet, - totalPoints, - billing, + resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress, + paymentReceiverAddress: paymentFlow.paymentReceiverAddress, + paymentWalletLabel: walletBind.paymentWalletLabel, + hasPayingWallet: walletBind.hasPayingWallet, + totalPoints: billing.totalPoints, + billing: billing.billing, // Callbacks loadSnapshot, - loadPaymentSnapshot, - connectAndBindWallet, - handleUnbindWallet, - createIntentAndPay, - createManualPaymentIntent, - submitManualPaymentTx, - validateTxHash, - handleOverlayCheckout, - openTelegramBotBindLink, + loadPaymentSnapshot: loadPaymentSnapshotImpl, + connectAndBindWallet: walletBind.connectAndBindWallet, + handleUnbindWallet: walletBind.handleUnbindWallet, + createIntentAndPay: paymentFlow.createIntentAndPay, + createManualPaymentIntent: paymentFlow.createManualPaymentIntent, + submitManualPaymentTx: paymentFlow.submitManualPaymentTx, + validateTxHash: paymentFlow.validateTxHash, + handleOverlayCheckout: paymentFlow.handleOverlayCheckout, + openTelegramBotBindLink: billing.openTelegramBotBindLink, }; -} +} \ No newline at end of file diff --git a/frontend/components/account/useBilling.ts b/frontend/components/account/useBilling.ts new file mode 100644 index 00000000..e2e58719 --- /dev/null +++ b/frontend/components/account/useBilling.ts @@ -0,0 +1,398 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { User } from "@supabase/supabase-js"; +import { + getAllowedPaymentHosts, + getCurrentPaymentHost, + isPaymentHostAllowed, +} from "@/lib/payment-host"; +import type { + AuthMeResponse, + PaymentConfig, + PaymentRecoveryState, + TelegramPricing, +} from "./types"; +import { + PAYMENT_RECOVERY_STORAGE_KEY, + PAYMENT_RECOVERY_TTL_MS, +} from "./constants"; +import { clearStoredPaymentRecovery, shortAddress } from "./formatters"; +import { normalizePaymentError } from "./payment-utils"; +import { trackAppEvent } from "@/lib/app-analytics"; + +// ============================================================ +export interface UseBillingParams { + isEn: boolean; + copy: Record; + backend: AuthMeResponse | null; + supabaseReady: boolean; + + // Shared state from master + paymentConfig: PaymentConfig | null; + authUserId: string; + authIsAuthenticated: boolean; + usePoints: boolean; + setUsePoints: (v: boolean) => void; + + // Payment UI state from usePaymentState + lastIntentId: string; + lastTxHash: string; + lastPaymentStartedAt: number; + setLastIntentId: (v: string) => void; + setLastTxHash: (v: string) => void; + setLastPaymentStartedAt: (v: number) => void; + setPaymentBusy: (v: boolean) => void; + setPaymentInfo: (v: string) => void; + setPaymentError: (v: string) => void; + setTelegramBindOpening: (v: boolean) => void; + clearPaymentState: () => void; + + // Derived / callbacks from master + selectedPlan?: { plan_code?: string; amount_usdc?: string } | undefined; + getValidAccessToken: () => Promise; + buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; + loadSnapshot: () => Promise; + loadPaymentSnapshot: () => Promise; + + // User for metadata points + user: User | null; +} + +// ============================================================ +export function useBilling(params: UseBillingParams) { + const { + isEn, + copy, + backend, + supabaseReady, + paymentConfig, + authUserId, + authIsAuthenticated, + usePoints, + setUsePoints, + lastIntentId, + lastTxHash, + lastPaymentStartedAt, + setLastIntentId, + setLastTxHash, + setLastPaymentStartedAt, + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setTelegramBindOpening, + clearPaymentState, + selectedPlan, + getValidAccessToken, + buildAuthedHeaders, + loadSnapshot, + loadPaymentSnapshot, + user, + } = params; + + // ── Billing-specific state ──────────────────────────────── + const [reconcileBusy, setReconcileBusy] = useState(false); + const [telegramBindUrl, setTelegramBindUrl] = useState(""); + + // ── Derived values ────────────────────────────────────── + const paymentReadyForRecovery = Boolean(paymentConfig?.enabled && paymentConfig?.configured); + const hasRecentPaymentRecovery = + Boolean(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt) && + Date.now() - lastPaymentStartedAt <= PAYMENT_RECOVERY_TTL_MS; + const allowedPaymentHosts = useMemo(() => getAllowedPaymentHosts(), []); + const currentPaymentHost = useMemo(() => getCurrentPaymentHost(), []); + const paymentHostAllowed = useMemo(() => isPaymentHostAllowed(currentPaymentHost), [currentPaymentHost]); + + // ── Points computation ────────────────────────────────── + 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 totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0; + + // ── Billing ────────────────────────────────────────────── + const billing = useMemo(() => { + const parsedPlanAmount = Number( + backend?.telegram_pricing?.amount_usdc ?? selectedPlan?.amount_usdc ?? 10, + ); + const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount : 10; + + 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, + backend?.telegram_pricing?.amount_usdc, + selectedPlan?.amount_usdc, + totalPoints, + usePoints, + ]); + + // ── reconcileLatestPayment ────────────────────────────── + const reconcileLatestPayment = useCallback(async () => { + if (!authIsAuthenticated || reconcileBusy) return false; + setReconcileBusy(true); + try { + const headers = await buildAuthedHeaders(true, true); + const res = await fetch("/api/payments/reconcile-latest", { + method: "POST", headers, + }); + if (!res.ok) return false; + const json = (await res.json()) as { + ok?: boolean; action?: string; subscription?: { plan_code?: string | null } | null; + }; + if (json.ok) { + setPaymentInfo(copy.walletRecoveryDone); + setPaymentError(""); + await loadSnapshot(); + await loadPaymentSnapshot(); + return true; + } + return false; + } catch { + return false; + } finally { + setReconcileBusy(false); + } + }, [ + authIsAuthenticated, buildAuthedHeaders, copy.walletRecoveryDone, + loadPaymentSnapshot, loadSnapshot, reconcileBusy, + ]); + + // ── handleSubmit409 ──────────────────────────────────── + const handleSubmit409 = useCallback( + async (intentId: string, txHashNorm: string, raw: string) => { + const lowerRaw = raw.toLowerCase(); + if (lowerRaw.includes("已支付") || lowerRaw.includes("already confirmed") || lowerRaw.includes("already paid")) { + const ok = await reconcileLatestPayment(); + if (ok) return; + setPaymentInfo(copy.orderAlreadyPaid); + await loadSnapshot(); + await loadPaymentSnapshot(); + return; + } + if (lowerRaw.includes("expired")) { + throw new Error(copy.orderExpired); + } + try { + const headers = await buildAuthedHeaders(true, false); + const intentRes = await fetch(`/api/payments/intents/${intentId}`, { headers, cache: "no-store" }); + if (intentRes.ok) { + const intentJson = (await intentRes.json()) as { + intent?: { status?: string; tx_hash?: string }; + }; + const status = intentJson.intent?.status; + if (status === "confirmed") { + await reconcileLatestPayment(); + const txHash = intentJson.intent?.tx_hash || txHashNorm; + setPaymentInfo(`支付已确认,交易: ${shortAddress(txHash)}`); + setPaymentError(""); + await loadSnapshot(); + await loadPaymentSnapshot(); + return; + } + if (status === "expired") throw new Error(copy.orderExpired); + } + } catch (e) { + if (e instanceof Error && e.message !== raw) throw e; + } + throw new Error(copy.submitTxFailed.replace("{raw}", raw)); + }, + [buildAuthedHeaders, copy, loadPaymentSnapshot, loadSnapshot, reconcileLatestPayment], + ); + + // ── openTelegramBotBindLink ────────────────────────────── + const openTelegramBotBindLink = async () => { + setTelegramBindOpening(true); + setPaymentError(""); + setTelegramBindUrl(""); + const popup = window.open("about:blank", "_blank", "noopener,noreferrer"); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const res = await fetch("/api/auth/telegram/bot-bind-link", { + method: "POST", headers: authHeaders, + }); + if (!res.ok) { + const raw = (await res.text()).slice(0, 300); + throw new Error(raw || copy.telegramBindFailed); + } + const data = (await res.json()) as { bot_url?: string }; + const botUrl = String(data.bot_url || "").trim(); + if (!botUrl) throw new Error(copy.telegramBindLinkMissing); + if (popup && !popup.closed) { + popup.location.href = botUrl; + setPaymentInfo(copy.telegramBindClickHint); + } else { + setPaymentInfo(copy.telegramPopupBlocked); + setTelegramBindUrl(botUrl); + } + } catch (error) { + if (popup && !popup.closed) popup.close(); + setPaymentError(normalizePaymentError(error).message); + } finally { + setTelegramBindOpening(false); + } + }; + + // ── loadPaymentSnapshot effect ──────────────────────────── + useEffect(() => { + void loadPaymentSnapshot(); + }, [loadPaymentSnapshot]); + + // ── Recovery save effect ──────────────────────────────── + useEffect(() => { + if (typeof window === "undefined") return; + if (!(lastIntentId && lastTxHash && authUserId && lastPaymentStartedAt)) { + clearStoredPaymentRecovery(); + return; + } + const payload: PaymentRecoveryState = { + intentId: lastIntentId, + txHash: lastTxHash, + userId: authUserId, + createdAt: lastPaymentStartedAt, + }; + window.sessionStorage.setItem(PAYMENT_RECOVERY_STORAGE_KEY, JSON.stringify(payload)); + }, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]); + + // ── Recovery restore effect ────────────────────────────── + useEffect(() => { + if (typeof window === "undefined") return; + if (!authUserId) return; + if (lastIntentId && lastTxHash && lastPaymentStartedAt) return; + const raw = window.sessionStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY); + if (!raw) return; + try { + const parsed = JSON.parse(raw) as PaymentRecoveryState; + const userId = String(parsed?.userId || "").trim(); + const intentId = String(parsed?.intentId || "").trim(); + const txHash = String(parsed?.txHash || "").trim().toLowerCase(); + const createdAt = Number(parsed?.createdAt || 0); + const expired = !createdAt || Date.now() - createdAt > PAYMENT_RECOVERY_TTL_MS; + if (expired || !intentId || !txHash || !userId || userId !== authUserId) { + clearStoredPaymentRecovery(); + return; + } + setLastIntentId(intentId); + setLastTxHash(txHash); + setLastPaymentStartedAt(createdAt); + } catch { + clearStoredPaymentRecovery(); + } + }, [authUserId, lastIntentId, lastPaymentStartedAt, lastTxHash]); + + // ── Subscription cleanup effect ────────────────────────── + useEffect(() => { + if (!backend?.subscription_active) return; + clearPaymentState(); + clearStoredPaymentRecovery(); + }, [backend?.subscription_active]); + + // ── Auto-reconcile effect ────────────────────────────── + useEffect(() => { + if (!authIsAuthenticated) return; + if (backend?.subscription_active) return; + if (!paymentReadyForRecovery) return; + if (!hasRecentPaymentRecovery) return; + let cancelled = false; + const run = async () => { + setPaymentInfo(copy.walletRecoveryBusy); + const repaired = await reconcileLatestPayment(); + if (cancelled) return; + if (!repaired && !backend?.subscription_active) { + setPaymentInfo(""); + setPaymentError(copy.walletRecoveryFailed); + } + }; + void run(); + return () => { cancelled = true; }; + }, [ + backend?.subscription_active, authIsAuthenticated, copy.walletRecoveryBusy, + copy.walletRecoveryFailed, hasRecentPaymentRecovery, + paymentReadyForRecovery, reconcileLatestPayment, + ]); + + // ── bind_token URL effect ────────────────────────────── + useEffect(() => { + if (!authIsAuthenticated) return; + const params = new URLSearchParams(window.location.search); + const token = params.get("bind_token"); + if (!token) return; + const url = new URL(window.location.href); + url.searchParams.delete("bind_token"); + window.history.replaceState(null, "", url.toString()); + (async () => { + setPaymentError(""); + setPaymentInfo(""); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const res = await fetch("/api/auth/telegram/bind-by-token", { + method: "POST", headers: authHeaders, body: JSON.stringify({ token }), + }); + if (!res.ok) { + const raw = (await res.text()).slice(0, 350); + throw new Error(copy.bindFailed.replace("{raw}", raw)); + } + const data = (await res.json()) as { telegram_pricing?: TelegramPricing | null }; + if (data.telegram_pricing?.is_group_member) { + const amount = data.telegram_pricing.amount_usdc || "10"; + setPaymentInfo(copy.telegramVerifySuccess.replace("{amount}", amount)); + } + await loadSnapshot(); + await loadPaymentSnapshot(); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } + })(); + }, [authIsAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]); + + // ========================================================== + return { + reconcileBusy, + telegramBindUrl, + setTelegramBindUrl, + reconcileLatestPayment, + handleSubmit409, + openTelegramBotBindLink, + paymentReadyForRecovery, + hasRecentPaymentRecovery, + allowedPaymentHosts, + currentPaymentHost, + paymentHostAllowed, + totalPoints, + billing, + }; +} diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts new file mode 100644 index 00000000..c012b0bc --- /dev/null +++ b/frontend/components/account/usePaymentFlow.ts @@ -0,0 +1,708 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import type { + AuthMeResponse, + BoundWallet, + ConnectBindOptions, + CreatedIntent, + EvmProvider, + IntentStatusResponse, + PaymentConfig, + PaymentTokenOption, + ProviderMode, + ProviderSelection, +} from "./types"; +import { + WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, +} from "./constants"; +import { clearStoredPaymentRecovery, shortAddress } from "./formatters"; +import { + buildAllowanceCalldata, + buildApproveCalldata, + buildBalanceOfCalldata, + formatTokenUnits, + normalizePaymentError, + requestWalletWithTimeout, +} from "./payment-utils"; +import { trackAppEvent } from "@/lib/app-analytics"; +import { + assertExpectedPaymentReceiver, + EXPECTED_PAYMENT_RECEIVER_ADDRESS, +} from "@/lib/payment-receiver"; +import type { PaymentTxValidationState } from "./usePaymentState"; + +// ============================================================ +export interface UsePaymentFlowParams { + isEn: boolean; + copy: Record; + backend: AuthMeResponse | null; + + // Shared payment config state + paymentConfig: PaymentConfig | null; + setPaymentConfig: React.Dispatch>; + selectedPlanCode: string; + setSelectedPlanCode: React.Dispatch>; + selectedTokenAddress: string; + setSelectedTokenAddress: React.Dispatch>; + + // Shared wallet state (read-only) + boundWallets: BoundWallet[]; + selectedWallet: string; + walletAddress: string; + providerMode: ProviderMode; + selectedInjectedProviderKey: string; + + // Payment UI state + setters + manualTxHash: string; + manualPayment: CreatedIntent["direct_payment"] | null; + lastIntentId: string; + setPaymentBusy: (v: boolean) => void; + setPaymentInfo: (v: string) => void; + setPaymentError: (v: string) => void; + setLastIntentId: (v: string) => void; + setLastTxHash: (v: string) => void; + setLastPaymentStartedAt: (v: number) => void; + setShowOverlay: (v: boolean) => void; + setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void; + setManualTxHash: (v: string) => void; + setTxValidation: (v: PaymentTxValidationState) => void; + setPaymentMethodTab: (v: "wallet" | "manual") => void; + clearPaymentMessages: () => void; + clearPaymentState: () => void; + + // Setters for shared state + setSelectedWallet: React.Dispatch>; + setProviderMode: React.Dispatch>; + + // Derived values from master + selectedPlan?: { plan_code?: string; amount_usdc?: string }; + billing: { + planAmount: number; + pointsEnabled: boolean; + pointsPerUsdc: number; + maxDiscountUsdc: number; + pointsUsed: number; + discountAmount: number; + payAmount: number; + canRedeem: boolean; + }; + usePoints: boolean; + currentPaymentHost: string; + paymentHostAllowed: boolean; + allowedPaymentHosts: string[]; + authIsAuthenticated: boolean; + hasPayingWallet: boolean; + + // Callbacks from master + getValidAccessToken: () => Promise; + buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; + loadSnapshot: () => Promise; + loadPaymentSnapshot: () => Promise; + waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise; + ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise; + + // Ref-wrapped cross-hook callbacks + connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise) | null>; + handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise) | null>; + + // Wallet provider helpers + resolvePaymentProvider: (mode?: ProviderMode, preferredInjectedKey?: string) => Promise; +} + +// ============================================================ +export function usePaymentFlow(params: UsePaymentFlowParams) { + const { + isEn, + copy, + backend, + paymentConfig, + setPaymentConfig, + selectedPlanCode, + setSelectedPlanCode, + selectedTokenAddress, + setSelectedTokenAddress, + boundWallets, + selectedWallet, + walletAddress, + providerMode, + selectedInjectedProviderKey, + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setLastIntentId, + setLastTxHash, + setLastPaymentStartedAt, + setShowOverlay, + setManualPayment, + setManualTxHash, + setTxValidation, + manualTxHash, + manualPayment, + lastIntentId, + setPaymentMethodTab, + clearPaymentMessages, + clearPaymentState, + setSelectedWallet, + setProviderMode, + billing, + usePoints, + currentPaymentHost, + paymentHostAllowed, + allowedPaymentHosts, + authIsAuthenticated, + hasPayingWallet, + getValidAccessToken, + buildAuthedHeaders, + loadSnapshot, + loadPaymentSnapshot, + waitForReceipt, + ensureTargetChain, + connectAndBindWalletRef, + handleSubmit409Ref, + resolvePaymentProvider, + } = params; + + // ── Derived payment values ────────────────────────────── + 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 paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS; + + // ── fetchLatestPaymentConfig ──────────────────────────── + const fetchLatestPaymentConfig = useCallback( + async (authHeaders?: Record, syncState = true): Promise => { + const headers = authHeaders || (await buildAuthedHeaders(false)); + const configRes = await fetch("/api/payments/config", { cache: "no-store", headers }); + if (!configRes.ok) { + const raw = (await configRes.text()).slice(0, 350); + throw new Error(copy.loadConfigFailed.replace("{raw}", raw)); + } + const configJson = (await configRes.json()) as PaymentConfig; + if (syncState) { + 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: string) => prev || defaultTokenAddress); + } + } + return configJson; + }, + [buildAuthedHeaders, selectedPlanCode], + ); + + // ── pollIntentUntilConfirmed ──────────────────────────── + 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(copy.queryIntentFailed.replace("{raw}", 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(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash))); + trackAppEvent("checkout_succeeded", { + entry: "account_center", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + tx_hash: txHash || null, + }); + await loadSnapshot(); + await loadPaymentSnapshot(); + return; + } + if (status === "failed" || status === "cancelled" || status === "expired") { + throw new Error(copy.paymentStatus.replace("{status}", status)); + } + setPaymentInfo(copy.txSubmitted.replace("{txHash}", shortTx).replace("{status}", status || "submitted")); + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + throw new Error(copy.paymentPendingTimeout); + }, + [loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code], + ); + + // ── createIntentAndPay ────────────────────────────────── + const createIntentAndPay = async () => { + clearPaymentMessages(); + clearPaymentState(); + clearStoredPaymentRecovery(); + if (!paymentHostAllowed) { + setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather-pro.vercel.app")); + return; + } + if (!authIsAuthenticated) { + setPaymentError(copy.loginBeforePay); + return; + } + if (!paymentConfig?.configured) { + setPaymentError(copy.payNotReady); + return; + } + + const fallbackWallet = String(selectedWallet || walletAddress || boundWallets[0]?.address || "").toLowerCase(); + if (!fallbackWallet) { + setPaymentError(copy.bindFirstBeforePay); + return; + } + + setPaymentBusy(true); + let approvedInThisRun = false; + try { + const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey); + const eth = providerSelection.provider; + const activeAccounts = await requestWalletWithTimeout( + eth, { method: "eth_requestAccounts" }, "连接付款钱包", + ); + const activeAddress = String(activeAccounts?.[0] || "").toLowerCase(); + if (!activeAddress) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空"); + + 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); + + 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 latestConfig = await fetchLatestPaymentConfig(authHeaders, true); + if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady); + const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase(); + assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract"); + if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) { + setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver))); + } else { + setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver))); + } + + const targetChainId = Number(latestConfig.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", + frontend_host: currentPaymentHost || null, + account_email: backend?.email || null, + }, + }), + }); + if (!createRes.ok) { + const raw = (await createRes.text()).slice(0, 350); + throw new Error(copy.createIntentFailed.replace("{raw}", 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(copy.intentPayloadInvalid); + trackAppEvent("checkout_started", { + entry: "account_center", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + use_points: billing.canRedeem && usePoints, + pay_amount_usd: billing.payAmount, + }); + const intentReceiver = String(txPayload.to || "").toLowerCase(); + if (intentReceiver !== expectedReceiver) { + throw new Error(`payment receiver changed: expected ${expectedReceiver}, got ${intentReceiver}. 请刷新页面后重试。`); + } + 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(copy.intentTokenInvalid); + const tokenSymbol = String(txPayload.token_symbol || selectedPaymentToken?.symbol || selectedTokenLabel || "USDC"); + const tokenDecimals = Number(txPayload.token_decimals ?? selectedPaymentToken?.decimals ?? latestConfig?.token_decimals ?? 6); + + const balanceHex = await requestWalletWithTimeout( + eth, { method: "eth_call", params: [{ to: tokenAddress, data: buildBalanceOfCalldata(payingWallet) }, "latest"] }, + `读取 ${tokenSymbol} 余额`, + ); + 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 requestWalletWithTimeout( + eth, { method: "eth_call", params: [{ to: tokenAddress, data: buildAllowanceCalldata(payingWallet, txPayload.to) }, "latest"] }, + `读取 ${tokenSymbol} 授权额度`, + ); + const allowance = BigInt(String(allowanceHex || "0x0")); + + if (allowance < amountUnits) { + setPaymentInfo(copy.approvalDetected.replace("{symbol}", tokenSymbol)); + const approveParams: Record = { + from: payingWallet, to: tokenAddress, data: buildApproveCalldata(txPayload.to, amountUnits), + }; + const approveHash = await requestWalletWithTimeout( + eth, { method: "eth_sendTransaction", params: [approveParams] }, + `发起 ${tokenSymbol} 授权`, WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, + ); + await waitForReceipt(String(approveHash || ""), eth); + approvedInThisRun = true; + setPaymentInfo(copy.approvalDone.replace("{symbol}", tokenSymbol)); + } else { + setPaymentInfo(copy.approvalSufficient); + } + + const payParams: Record = { from: payingWallet, to: txPayload.to, data: txPayload.data }; + if (txPayload.value && txPayload.value !== "0x0" && txPayload.value !== "0") { + payParams.value = txPayload.value; + } + + const txHash = await requestWalletWithTimeout( + eth, { method: "eth_sendTransaction", params: [payParams] }, + "发起支付交易", WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, + ); + const txHashNorm = String(txHash || "").toLowerCase(); + setLastTxHash(txHashNorm); + setLastPaymentStartedAt(Date.now()); + + 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); + if (submitRes.status === 409) { + if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw); + return; + } + throw new Error(copy.submitTxFailed.replace("{raw}", 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(copy.confirmFailed.replace("{raw}", raw)); + } + + setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm))); + trackAppEvent("checkout_succeeded", { + entry: "account_center", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + tx_hash: 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); + } + }; + + // ── createManualPaymentIntent ──────────────────────────── + const createManualPaymentIntent = async () => { + clearPaymentMessages(); + setManualPayment(null); + setManualTxHash(""); + setLastIntentId(""); + setLastTxHash(""); + if (!paymentHostAllowed) { + setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather-pro.vercel.app")); + return; + } + if (!authIsAuthenticated) { + setPaymentError(copy.loginBeforePay); + return; + } + if (!paymentConfig?.configured) { + setPaymentError(copy.payNotReady); + return; + } + + setPaymentBusy(true); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const createRes = await fetch("/api/payments/intents", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + plan_code: selectedPlan?.plan_code || "pro_monthly", + payment_mode: "direct", + token_address: resolvedSelectedTokenAddress || undefined, + use_points: billing.canRedeem && usePoints, + points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, + metadata: { + source: "account_center_manual_transfer", + frontend_host: currentPaymentHost || null, + account_email: backend?.email || null, + }, + }), + }); + if (!createRes.ok) { + const raw = (await createRes.text()).slice(0, 350); + throw new Error(copy.createManualIntentFailed.replace("{raw}", raw)); + } + + const created = (await createRes.json()) as CreatedIntent; + const direct = created.direct_payment; + const intentId = String(created.intent?.intent_id || direct?.intent_id || ""); + if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) { + throw new Error(copy.manualPaymentInvalid); + } + assertExpectedPaymentReceiver(direct.receiver_address, "manual payment receiver"); + setLastIntentId(intentId); + setManualPayment(direct); + setPaymentMethodTab("manual"); + setShowOverlay(false); + setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel} 到 ${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`); + trackAppEvent("checkout_started", { + entry: "account_center_manual_transfer", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentId, + payment_mode: "direct", + use_points: billing.canRedeem && usePoints, + pay_amount_usd: billing.payAmount, + }); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } finally { + setPaymentBusy(false); + } + }; + + // ── submitManualPaymentTx ────────────────────────────── + const submitManualPaymentTx = async () => { + const txHashNorm = String(manualTxHash || "").trim().toLowerCase(); + const intentIdVal = String(lastIntentId || manualPayment?.intent_id || "").trim(); + if (!intentIdVal || !manualPayment) { + setPaymentError(copy.manualOrderRequired); + return; + } + if (!txHashNorm.startsWith("0x") || txHashNorm.length !== 66) { + setPaymentError(copy.txHashRequired); + return; + } + setPaymentBusy(true); + setPaymentError(""); + try { + const authHeaders = await buildAuthedHeaders(true, false); + const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, { + method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), + }); + if (!submitRes.ok) { + const raw = (await submitRes.text()).slice(0, 350); + if (submitRes.status === 409) { + if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw); + return; + } + throw new Error(copy.submitTxFailed.replace("{raw}", raw)); + } + const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/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 === 408 || + (confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially"))); + if (maybePending) { + setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`); + await pollIntentUntilConfirmed(intentIdVal, authHeaders, txHashNorm); + return; + } + throw new Error(copy.confirmFailed.replace("{raw}", raw)); + } + setLastTxHash(txHashNorm); + setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm))); + setManualPayment(null); + setManualTxHash(""); + setTxValidation({ loading: false, checked: false }); + trackAppEvent("checkout_succeeded", { + entry: "account_center_manual_transfer", + plan_code: selectedPlan?.plan_code || "pro_monthly", + intent_id: intentIdVal, + tx_hash: txHashNorm, + }); + await loadSnapshot(); + await loadPaymentSnapshot(); + } catch (error) { + setPaymentError(normalizePaymentError(error).message); + } finally { + setPaymentBusy(false); + } + }; + + // ── validateTxHash ──────────────────────────────────── + const validateTxHash = useCallback( + async (intentId: string, hash: string) => { + const hashNorm = String(hash || "").trim().toLowerCase(); + if (!hashNorm.startsWith("0x") || hashNorm.length !== 66) { + setTxValidation({ loading: false, checked: false }); + return; + } + setTxValidation({ loading: true, checked: false }); + try { + const headers = await buildAuthedHeaders(true, false); + const res = await fetch(`/api/payments/intents/${intentId}/validate`, { + method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }), + }); + const json = (await res.json()) as { + valid?: boolean; reason?: string; detail?: string; checks?: Record; + }; + setTxValidation({ loading: false, checked: true, valid: Boolean(json.valid), reason: json.reason, detail: json.detail, checks: json.checks }); + } catch { + setTxValidation({ loading: false, checked: false }); + } + }, + [buildAuthedHeaders], + ); + + // ── handleOverlayCheckout ────────────────────────────── + const handleOverlayCheckout = async () => { + if (!paymentHostAllowed) { + setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather-pro.vercel.app")); + return; + } + if (!authIsAuthenticated) { + setPaymentError(copy.loginBeforePay); + return; + } + if (!hasPayingWallet) { + setPaymentInfo(copy.openBindFlow); + if (connectAndBindWalletRef.current) { + const bound = await connectAndBindWalletRef.current(providerMode, { openOverlayAfterBind: true }); + if (!bound) return; + } + setPaymentInfo(copy.walletBoundCreatingOrder); + await createIntentAndPay(); + return; + } + await createIntentAndPay(); + }; + + // ========================================================== + return { + fetchLatestPaymentConfig, + pollIntentUntilConfirmed, + createIntentAndPay, + createManualPaymentIntent, + submitManualPaymentTx, + validateTxHash, + handleOverlayCheckout, + availableTokenList, + resolvedSelectedTokenAddress, + selectedPaymentToken, + selectedTokenLabel, + paymentReceiverAddress, + }; +} diff --git a/frontend/components/account/useWalletBind.ts b/frontend/components/account/useWalletBind.ts new file mode 100644 index 00000000..8f944092 --- /dev/null +++ b/frontend/components/account/useWalletBind.ts @@ -0,0 +1,386 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import type { + BoundWallet, + ConnectBindOptions, + Eip6963ProviderDetail, + EvmProvider, + InjectedProviderOption, + ProviderMode, + ProviderSelection, +} from "./types"; +import { + WALLETCONNECT_POLYGON_RPC_URL, + WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, +} from "./constants"; +import { shortAddress } from "./formatters"; +import { normalizePaymentError, requestWalletWithTimeout } from "./payment-utils"; +import { + eip6963Providers, + getEvmProvider, + getEvmWalletLabel, + getWalletConnectProvider, + isWalletConnectResetError, + listInjectedProviders, + resetWalletConnectProvider, +} from "./wallet"; + +// ============================================================ +export interface UseWalletBindParams { + isEn: boolean; + walletConnectEnabled: boolean; + copy: Record; + chainId: number; + + // Shared wallet state + setters + boundWallets: BoundWallet[]; + setBoundWallets: React.Dispatch>; + walletAddress: string; + setWalletAddress: React.Dispatch>; + selectedWallet: string; + setSelectedWallet: React.Dispatch>; + providerMode: ProviderMode; + setProviderMode: React.Dispatch>; + injectedProviderOptions: InjectedProviderOption[]; + setInjectedProviderOptions: React.Dispatch>; + selectedInjectedProviderKey: string; + setSelectedInjectedProviderKey: React.Dispatch>; + + // Payment UI state setters + setPaymentBusy: (v: boolean) => void; + setPaymentInfo: (v: string) => void; + setPaymentError: (v: string) => void; + setShowOverlay: (v: boolean) => void; + clearPaymentMessages: () => void; + + // Derived values from master + authIsAuthenticated: boolean; + + // Callbacks from master (must be stable via useCallback) + getValidAccessToken: () => Promise; + buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise>; + loadSnapshot: () => Promise; + loadPaymentSnapshot: () => Promise; +} + +// ============================================================ +export function useWalletBind(params: UseWalletBindParams) { + const { + isEn, + walletConnectEnabled, + copy, + chainId, + boundWallets, + setBoundWallets, + walletAddress, + setWalletAddress, + selectedWallet, + setSelectedWallet, + providerMode, + setProviderMode, + injectedProviderOptions, + setInjectedProviderOptions, + selectedInjectedProviderKey, + setSelectedInjectedProviderKey, + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setShowOverlay, + clearPaymentMessages, + authIsAuthenticated, + getValidAccessToken, + buildAuthedHeaders, + loadSnapshot, + loadPaymentSnapshot, + } = params; + + // ── EIP-6963 Provider sync ────────────────────────────── + useEffect(() => { + const syncProviders = () => { + const nextOptions = listInjectedProviders(); + setInjectedProviderOptions(nextOptions); + setSelectedInjectedProviderKey((current) => { + if (current && nextOptions.some((row) => row.key === current)) { + return current; + } + return nextOptions[0]?.key || ""; + }); + }; + + const handleAnnounce = (event: Event) => { + const customEvent = event as CustomEvent; + const detail = customEvent.detail; + if (!detail?.provider || typeof detail.provider.request !== "function") return; + const uuid = String(detail.info?.uuid || "").trim(); + const fallbackKey = `${String(detail.info?.rdns || "wallet").toLowerCase()}:${String(detail.info?.name || "wallet").toLowerCase()}`; + eip6963Providers.set(uuid || fallbackKey, detail); + syncProviders(); + }; + + syncProviders(); + if (typeof window === "undefined") return; + window.addEventListener("eip6963:announceProvider", handleAnnounce as EventListener); + window.dispatchEvent(new Event("eip6963:requestProvider")); + window.addEventListener("ethereum#initialized", syncProviders as EventListener, { once: false }); + return () => { + window.removeEventListener("eip6963:announceProvider", handleAnnounce as EventListener); + window.removeEventListener("ethereum#initialized", syncProviders as EventListener); + }; + }, []); + + // ── resolvePaymentProvider ────────────────────────────── + const resolvePaymentProvider = useCallback( + async (mode: ProviderMode = "auto", preferredInjectedKey = ""): Promise => { + const targetChainId = Number(chainId || 137); + if (mode !== "walletconnect") { + const injectedOptions = listInjectedProviders(); + const injected = injectedOptions.find((row) => row.key === preferredInjectedKey)?.provider || getEvmProvider(); + const label = injectedOptions.find((row) => row.key === preferredInjectedKey)?.label || getEvmWalletLabel(injected); + if (injected) return { provider: injected, label, 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" }; + }, + [chainId, walletConnectEnabled], + ); + + // ── Low-level helpers ────────────────────────────────── + const waitForReceipt = async (txHash: string, provider?: EvmProvider, timeoutMs = 120000, pollMs = 3000) => { + const eth = provider || getEvmProvider(); + if (!eth) throw new Error(copy.noWalletProvider); + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const receipt = await requestWalletWithTimeout<{ status?: string } | null>( + eth, + { method: "eth_getTransactionReceipt", params: [txHash] }, + "查询授权交易确认", + 15_000, + ); + if (receipt && receipt.status) { + if (receipt.status === "0x1") return receipt; + throw new Error(copy.txReverted.replace("{txHash}", txHash)); + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + throw new Error(copy.txConfirmTimeout.replace("{txHash}", txHash)); + }; + + const signBindMessage = async (eth: EvmProvider, address: string, message: string): Promise => { + try { + return (await eth.request({ method: "personal_sign", params: [message, address] })) as string; + } catch { + return (await eth.request({ method: "personal_sign", params: [address, message] })) as string; + } + }; + + const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise => { + const currentChainIdHex = String( + (await requestWalletWithTimeout(eth, { method: "eth_chainId" }, copy.chainReadError)) || "", + ); + const targetChainHex = `0x${targetChainId.toString(16)}`; + if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return; + try { + await requestWalletWithTimeout( + eth, + { method: "wallet_switchEthereumChain", params: [{ chainId: targetChainHex }] }, + copy.chainSwitchError, + ); + } catch (err: any) { + const code = Number(err?.code); + if (code === 4902 || targetChainId === 137) { + try { + await requestWalletWithTimeout( + eth, + { + 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"], + }], + }, + copy.chainAddPolygon, + ); + return; + } catch (addErr: any) { err = addErr; } + } + throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`); + } + }; + + // ── connectAndBindWallet ──────────────────────────────── + const connectAndBindWallet = async (mode: ProviderMode = "auto", options: ConnectBindOptions = {}): Promise => { + clearPaymentMessages(); + if (!authIsAuthenticated) { + setPaymentError(copy.loginBeforeBind); + return false; + } + + setPaymentBusy(true); + try { + const providerSelection = await resolvePaymentProvider(mode, selectedInjectedProviderKey); + const eth = providerSelection.provider; + const walletLabel = providerSelection.label; + const binanceBindHint = walletLabel.toLowerCase().includes("binance") ? copy.binanceBindHint : ""; + + 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 requestWalletWithTimeout(eth, { method: "eth_requestAccounts" }, "连接绑定钱包"); + const address = String(accounts?.[0] || "").toLowerCase(); + if (!address) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空"); + + const existingWallet = boundWallets.find((w) => String(w.address || "").toLowerCase() === address); + if (existingWallet) { + setWalletAddress(address); + setSelectedWallet(address); + setPaymentInfo(`${walletLabel} 已绑定: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`); + await Promise.all([loadSnapshot(), loadPaymentSnapshot()]); + 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(copy.challengeFailed.replace("{raw}", 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(copy.challengeInvalid); + + 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(copy.verifyFailedRaw.replace("{raw}", raw)); + } + + setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}。${binanceBindHint || "现在可点击“立即订阅并激活服务”。"}`); + setProviderMode(providerSelection.mode); + if (options.openOverlayAfterBind) setShowOverlay(true); + await Promise.all([loadSnapshot(), loadPaymentSnapshot()]); + return true; + } catch (error) { + setPaymentInfo(""); + setPaymentError(normalizePaymentError(error).message); + return false; + } finally { + setPaymentBusy(false); + } + }; + + // ── handleUnbindWallet ────────────────────────────────── + const handleUnbindWallet = async (address: string) => { + const target = String(address || "").toLowerCase(); + if (!target) return; + if (!authIsAuthenticated) { + setPaymentError(copy.loginBeforeBind); + return; + } + const confirmed = window.confirm(copy.unbindConfirm.replace("{address}", shortAddress(target))); + if (!confirmed) return; + + setPaymentBusy(true); + clearPaymentMessages(); + try { + const headers = await buildAuthedHeaders(true, false); + 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); + if (detail.trim().startsWith("{")) { + try { + const nested = JSON.parse(detail); + detail = String(nested?.detail || nested?.error || detail); + } catch { /* ignore nested parse failure */ } + } + } catch { /* ignore */ } + throw new Error(detail || copy.httpError.replace("{status}", String(res.status)).replace("{raw}", "")); + } + + let data: Record = {}; + try { data = raw ? (JSON.parse(raw) as Record) : {}; } + catch { data = {}; } + + const newPrimary = String(data?.new_primary || "").toLowerCase(); + const selectedWalletNorm = String(selectedWallet || "").toLowerCase(); + const walletAddressNorm = String(walletAddress || "").toLowerCase(); + if (selectedWalletNorm === target) setSelectedWallet(newPrimary || ""); + if (walletAddressNorm === target) setWalletAddress(newPrimary || ""); + setBoundWallets((prev) => prev.filter((row) => String(row.address || "").toLowerCase() !== String(target))); + await loadPaymentSnapshot(); + setPaymentInfo(newPrimary ? copy.unbindDonePrimary.replace("{address}", shortAddress(newPrimary)) : copy.unbindDone); + } catch (error) { + const message = normalizePaymentError(error).message; + const lower = String(message || "").toLowerCase(); + if (lower.includes("unauthorized") || lower.includes("session required") || lower.includes("401")) { + setPaymentError(`${copy.unbindFailed}: ${copy.authExpired}`); + return; + } + setPaymentError(`${copy.unbindFailed}: ${message}`); + } finally { + setPaymentBusy(false); + } + }; + + // ── Derived values ────────────────────────────────────── + const paymentWalletLabel = String( + selectedWallet || walletAddress || boundWallets.find((row) => row.is_primary)?.address || boundWallets[0]?.address || "", + ).toLowerCase(); + + const hasPayingWallet = Boolean(String(selectedWallet || walletAddress || boundWallets[0]?.address || "").trim()); + + // ========================================================== + return { + resolvePaymentProvider, + connectAndBindWallet, + handleUnbindWallet, + waitForReceipt, + signBindMessage, + ensureTargetChain, + paymentWalletLabel, + hasPayingWallet, + }; +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index f47ff8c7..587921e4 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -184,7 +184,7 @@ const TERM = { tsData: { en: "Data", zh: "数据" }, tsAccess: { en: "Access", zh: "访问权限" }, tsLayout: { en: "Layout", zh: "布局" }, - tsLayoutValue: { en: "Koyfin-style grid", zh: "Koyfin 风格网格" }, + tsLayoutValue: { en: "Multi-panel grid", zh: "多面板网格" }, tsDataLive: { en: "live", zh: "实时" }, tsAccessPaid: { en: "paid", zh: "付费" }, } as const; @@ -209,16 +209,6 @@ function decisionLabel(row?: ScanOpportunityRow | null) { return "Monitor"; } -function decisionClass(label: string) { - if (label === "Approve" || label === "Tradable") { - return "border-emerald-200 bg-emerald-50 text-emerald-700"; - } - if (label === "Veto" || label === "Downgrade") { - return "border-red-200 bg-red-50 text-red-700"; - } - return "border-amber-200 bg-amber-50 text-amber-700"; -} - function SparkArea({ color = "#2563eb", data, @@ -476,7 +466,7 @@ function GroupedMarketTable({ ); } -function KoyfinWeatherTerminal({ +function PolyWeatherTerminal({ generatedText, isEn, locale, @@ -1318,7 +1308,7 @@ function ScanTerminalScreen() { } return ( -