From 7f8dca20668a69607bd0e1fd0ddf1b0f80096747 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 02:38:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=20AccountCenter=EF=BC=9A?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E6=94=AF=E4=BB=98=E4=B8=8E=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E8=87=B3=20useAccountPayment=20hook=EF=BC=8C?= =?UTF-8?q?=E4=B8=BB=E7=BB=84=E4=BB=B6=E4=BB=8E=202917=20=E8=A1=8C?= =?UTF-8?q?=E5=87=8F=E8=87=B3=201280=20=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/account/AccountCenter.tsx | 2239 +++------------ .../account/__tests__/paymentSecurity.test.ts | 18 +- .../account/__tests__/paymentShell.test.ts | 13 +- .../components/account/useAccountPayment.ts | 2394 +++++++++++++++++ .../removedMonitorRunwayTabs.test.ts | 5 +- 5 files changed, 2723 insertions(+), 1946 deletions(-) create mode 100644 frontend/components/account/useAccountPayment.ts diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 42e5da4f..57d61e8d 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -39,45 +39,17 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; -import { - getAllowedPaymentHosts, - getCurrentPaymentHost, - isPaymentHostAllowed, -} from "@/lib/payment-host"; -import { - assertExpectedPaymentReceiver, - EXPECTED_PAYMENT_RECEIVER_ADDRESS, -} from "@/lib/payment-receiver"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; import { useI18n } from "@/hooks/useI18n"; import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay"; -import type { - AuthMeResponse, - BoundWallet, - ConnectBindOptions, - CreatedIntent, - Eip6963ProviderDetail, - EvmProvider, - InjectedProviderOption, - IntentStatusResponse, - PaymentConfig, - PaymentTokenOption, - PaymentRecoveryState, - ProviderMode, - ProviderSelection, - TelegramPricing, -} from "./types"; +import type { AuthMeResponse } from "./types"; import { - PAYMENT_RECOVERY_STORAGE_KEY, - PAYMENT_RECOVERY_TTL_MS, SUBSCRIPTION_HELP_HREF, TELEGRAM_BOT_URL, TELEGRAM_GROUP_URL, TELEGRAM_TOPICS_GROUP_URL, WALLETCONNECT_PROJECT_ID, - WALLETCONNECT_POLYGON_RPC_URL, - WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, } from "./constants"; import { InfoRow, PlusIcon } from "./AccountInfoRow"; import { @@ -87,25 +59,9 @@ import { parseSubscriptionExpiry, shortAddress, } from "./formatters"; -import { - buildAllowanceCalldata, - buildApproveCalldata, - buildBalanceOfCalldata, - formatTokenUnits, - normalizePaymentError, - requestWalletWithTimeout, -} from "./payment-utils"; import { createAccountCopy } from "./account-copy"; -import { usePaymentState } from "./usePaymentState"; -import { - eip6963Providers, - getEvmProvider, - getEvmWalletLabel, - getWalletConnectProvider, - isWalletConnectResetError, - listInjectedProviders, - resetWalletConnectProvider, -} from "./wallet"; +import { resetWalletConnectProvider } from "./wallet"; +import { useAccountPayment } from "./useAccountPayment"; // --- Main Component --- @@ -115,76 +71,121 @@ export function AccountCenter() { const isEn = locale === "en-US"; const copy = useMemo(() => createAccountCopy(isEn), [isEn]); + // ── UI-only state ────────────────────────────────────── const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [errorText, setErrorText] = useState(""); const [copied, setCopied] = useState(false); + const [showSecondarySections, setShowSecondarySections] = useState(false); + + // ── Shared state (declared in component, written by hook via setters) ─ const [showOverlay, setShowOverlay] = useState(false); const [usePoints, setUsePoints] = useState(true); + const [errorText, setErrorText] = useState(""); const [updatedAt, setUpdatedAt] = useState(""); const [user, setUser] = useState(null); const [backend, setBackend] = useState(null); - const [paymentConfig, setPaymentConfig] = useState( - null, - ); - const [boundWallets, setBoundWallets] = useState([]); - const [walletAddress, setWalletAddress] = useState(""); - const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); - const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); - const [selectedWallet, setSelectedWallet] = useState(""); - const [providerMode, setProviderMode] = useState("auto"); - const [injectedProviderOptions, setInjectedProviderOptions] = useState< - InjectedProviderOption[] - >([]); - const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = - useState(""); - const { - paymentBusy, - setPaymentBusy, - paymentInfo, - setPaymentInfo, - paymentError, - setPaymentError, - lastIntentId, - setLastIntentId, - lastTxHash, - setLastTxHash, - telegramBindOpening, - setTelegramBindOpening, - manualPayment, - setManualPayment, - manualTxHash, - setManualTxHash, - txValidation, - setTxValidation, - paymentMethodTab, - setPaymentMethodTab, - lastPaymentStartedAt, - setLastPaymentStartedAt, - clearPaymentMessages, - clearPaymentState, - } = usePaymentState(); - const [showSecondarySections, setShowSecondarySections] = useState(false); - const [reconcileBusy, setReconcileBusy] = useState(false); - const [telegramBindUrl, setTelegramBindUrl] = useState(""); const supabaseReady = hasSupabasePublicEnv(); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); - 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], - ); + // ── Hook ──────────────────────────────────────────────── + const { + // State from usePaymentState + paymentBusy, + paymentInfo, + paymentError, + lastIntentId, + lastTxHash, + lastPaymentStartedAt, + telegramBindOpening, + telegramBindUrl, + manualPayment, + manualTxHash, + txValidation, + paymentMethodTab, + clearPaymentMessages, + clearPaymentState, + + // Setters + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setLastIntentId, + setLastTxHash, + setLastPaymentStartedAt, + setTelegramBindOpening, + setPaymentMethodTab, + setManualPayment, + setManualTxHash, + setTxValidation, + + // Additional state + paymentConfig, + boundWallets, + walletAddress, + selectedPlanCode, + selectedTokenAddress, + selectedWallet, + providerMode, + injectedProviderOptions, + selectedInjectedProviderKey, + reconcileBusy, + + // Shared setters + setSelectedTokenAddress, + setSelectedWallet, + setSelectedInjectedProviderKey, + setProviderMode, + + // Derived values + authUserId, + authIsAuthenticated, + paymentReadyForRecovery, + hasRecentPaymentRecovery, + allowedPaymentHosts, + currentPaymentHost, + paymentHostAllowed, + selectedPlan, + selectedPaymentToken, + selectedTokenLabel, + availableTokenList, + effectivePlanList, + resolvedSelectedTokenAddress, + paymentReceiverAddress, + paymentWalletLabel, + hasPayingWallet, + totalPoints, + billing, + + // Callbacks + loadSnapshot, + loadPaymentSnapshot, + connectAndBindWallet, + handleUnbindWallet, + createIntentAndPay, + createManualPaymentIntent, + submitManualPaymentTx, + validateTxHash, + handleOverlayCheckout, + openTelegramBotBindLink, + } = useAccountPayment({ + isEn, + supabaseReady, + walletConnectEnabled, + copy, + backend, + user, + setUser, + setBackend, + setErrorText, + setUpdatedAt, + showOverlay, + setShowOverlay, + usePoints, + setUsePoints, + }); + + // ── Auth analytics effect ────────────────────────────── useEffect(() => { if (!authIsAuthenticated || !authUserId) return; const actorKey = authUserId.toLowerCase(); @@ -202,6 +203,7 @@ export function AccountCenter() { } }, [authIsAuthenticated, authUserId]); + // ── Idle callback effect ────────────────────────────── useEffect(() => { let canceled = false; let timeoutId: number | null = null; @@ -237,388 +239,7 @@ export function AccountCenter() { }; }, []); - 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, - ); - }; - }, []); - - /** - * Returns a valid access token, refreshing the session if the stored one - * is missing or close to expiry. Throws if the user is not authenticated. - */ - const getValidAccessToken = useCallback(async (): Promise => { - if (!supabaseReady) - throw new Error( - isEn - ? "Supabase is not configured. Unable to get auth token." - : "Supabase 未配置,无法获取登录凭证。", - ); - const client = getSupabaseBrowserClient(); - // First try the cached session. - 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 - ) { - return cachedToken; - } - if (cachedToken && (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0)) { - return cachedToken; - } - // Session missing or expired — force a refresh. - const { - data: { session: refreshed }, - error, - } = await client.auth.refreshSession(); - const refreshedToken = String(refreshed?.access_token || "").trim(); - if (refreshedToken) return refreshedToken; - if (cachedToken && Number.isFinite(expiresAtSec) && expiresAtSec > nowSec) { - return cachedToken; - } - throw new Error( - error?.message - ? isEn - ? `Session expired (${error.message}). Please sign out and sign in again.` - : `登录会话已失效 (${error.message}),请退出后重新登录。` - : isEn - ? "Session expired. Please sign out and sign in again." - : "登录会话已失效,请退出后重新登录。", - ); - }, [isEn, supabaseReady]); - - const buildAuthedHeaders = useCallback( - async ( - withJson = false, - requireAuth = false, - ): Promise> => { - const headers: Record = {}; - if (withJson) headers["Content-Type"] = "application/json"; - if (!supabaseReady) return headers; - try { - const token = await getValidAccessToken(); - headers.Authorization = `Bearer ${token}`; - } catch (error) { - if (requireAuth) throw error; - // Best-effort fallback: use current cached session token (if any) - // even when refresh failed, so same-origin API routes can still auth. - try { - const { - data: { session }, - } = await getSupabaseBrowserClient().auth.getSession(); - const fallbackToken = String(session?.access_token || "").trim(); - if (fallbackToken) { - headers.Authorization = `Bearer ${fallbackToken}`; - } - } catch { - // Non-authenticated page load — silently skip. - } - } - return headers; - }, - [supabaseReady, getValidAccessToken], - ); - - 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, - ); - const existingAccounts = (await wcProvider - .request({ method: "eth_accounts" }) - .catch(() => [])) as string[]; - if (!Array.isArray(existingAccounts) || existingAccounts.length === 0) { - if (typeof wcProvider.connect === "function") { - try { - await wcProvider.connect({ chains: [targetChainId] }); - } catch (err) { - if (!isWalletConnectResetError(err)) throw err; - await resetWalletConnectProvider(); - const freshProvider = await getWalletConnectProvider( - targetChainId, - WALLETCONNECT_POLYGON_RPC_URL, - ); - if (typeof freshProvider.connect === "function") { - await freshProvider.connect({ chains: [targetChainId] }); - } - return { - provider: freshProvider, - label: "WalletConnect", - mode: "walletconnect", - }; - } - } - } - return { - provider: wcProvider, - label: "WalletConnect", - mode: "walletconnect", - }; - }, - [paymentConfig?.chain_id, walletConnectEnabled], - ); - - const loadPaymentSnapshot = useCallback(async () => { - if (!backend?.authenticated) { - setPaymentConfig(null); - setBoundWallets([]); - return; - } - try { - const authHeadersPromise = buildAuthedHeaders(false); - const [configRes, walletsRes] = await Promise.all([ - authHeadersPromise.then((headers) => - fetch("/api/payments/config", { - cache: "no-store", - headers, - }), - ), - authHeadersPromise.then((headers) => - fetch("/api/payments/wallets", { - cache: "no-store", - headers, - }), - ), - ]); - if (configRes.ok) { - const configJson = (await configRes.json()) as PaymentConfig; - setPaymentConfig(configJson); - if (!selectedPlanCode && configJson.plans?.length) { - setSelectedPlanCode(configJson.plans[0].plan_code); - } - const tokenOptions = Array.isArray(configJson.tokens) - ? configJson.tokens.filter( - (row) => - typeof row?.address === "string" && - String(row.address).startsWith("0x"), - ) - : []; - const defaultTokenAddress = String( - configJson.default_token_address || - tokenOptions.find((row) => row.is_default)?.address || - tokenOptions[0]?.address || - configJson.token_address || - "", - ).toLowerCase(); - if (defaultTokenAddress) { - setSelectedTokenAddress((prev) => prev || defaultTokenAddress); - } - } - if (walletsRes.ok) { - const walletsJson = (await walletsRes.json()) as { - wallets?: BoundWallet[]; - }; - const wallets = ( - Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [] - ) - .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(), - })); - 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); - } - } else { - setSelectedWallet(""); - setWalletAddress(""); - } - } - } catch { - // ignore - } - }, [ - backend?.authenticated, - buildAuthedHeaders, - selectedPlanCode, - selectedWallet, - walletAddress, - ]); - - 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], - ); - - 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) { - // Supabase session may not be hydrated yet; wait and retry once. - 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]); - + // ── Initial load effect ──────────────────────────────── useEffect(() => { let cancelled = false; const run = async () => { @@ -632,62 +253,7 @@ export function AccountCenter() { }; }, [loadSnapshot]); - useEffect(() => { - void loadPaymentSnapshot(); - }, [loadPaymentSnapshot]); - - 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]); - - 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]); - - useEffect(() => { - if (!backend?.subscription_active) return; - clearPaymentState(); - clearStoredPaymentRecovery(); - }, [backend?.subscription_active]); - + // ── Refresh ──────────────────────────────────────────── const onRefresh = async () => { setRefreshing(true); await loadSnapshot(); @@ -695,135 +261,7 @@ export function AccountCenter() { setRefreshing(false); }; - 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, - ]); - - const handleSubmit409 = useCallback( - async (intentId: string, txHashNorm: string, raw: string) => { - const lowerRaw = raw.toLowerCase(); - // If intent was already confirmed (maybe by confirm loop), reconcile - 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 intent expired, tell user to create a new order - if (lowerRaw.includes("expired")) { - throw new Error(copy.orderExpired); - } - // Try fetching intent status as fallback - 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, - loadPaymentSnapshot, - loadSnapshot, - reconcileLatestPayment, - ], - ); - - 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, - ]); - + // ── Sign out ──────────────────────────────────────────── const onSignOut = async () => { clearPaymentState(); clearStoredPaymentRecovery(); @@ -838,48 +276,10 @@ export function AccountCenter() { router.replace("/"); }; - // --- Derived State --- + // ── Derived display values ────────────────────────────── const userId = backend?.user_id || user?.id || ""; const isAuthenticated = Boolean(userId); const email = backend?.email || user?.email || ""; - // Handle ?bind_token=xxx from Telegram bot /bind deep link - useEffect(() => { - if (!isAuthenticated) return; - const params = new URLSearchParams(window.location.search); - const token = params.get("bind_token"); - if (!token) return; - // Remove token from URL so refresh doesn't retry - 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); - } - })(); - }, [isAuthenticated, buildAuthedHeaders, loadPaymentSnapshot, loadSnapshot]); const displayName = String(user?.user_metadata?.full_name || "").trim() || (email ? String(email).split("@")[0] : "") || @@ -889,7 +289,9 @@ export function AccountCenter() { const isSubscribed = Boolean(backend?.subscription_active); const planCode = String(backend?.subscription_plan_code || "").trim(); const currentExpiryRaw = String( - backend?.subscription_expires_at || user?.user_metadata?.pro_expiry || "", + backend?.subscription_expires_at || + user?.user_metadata?.pro_expiry || + "", ).trim(); const totalExpiryRaw = String( backend?.subscription_total_expires_at || @@ -901,12 +303,15 @@ export function AccountCenter() { 0, Number(backend?.subscription_queued_days || 0), ); - const hasQueuedExtension = Boolean(isSubscribed && queuedExtensionDays > 0); - const canAccessPaidTelegramGroup = Boolean( - isSubscribed, + const hasQueuedExtension = Boolean( + isSubscribed && queuedExtensionDays > 0, ); - const telegramBound = Number(backend?.telegram_pricing?.telegram_id || 0) > 0; - const displayExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw; + const canAccessPaidTelegramGroup = Boolean(isSubscribed); + const telegramBound = + Number(backend?.telegram_pricing?.telegram_id || 0) > 0; + const displayExpiryRaw = isSubscribed + ? totalExpiryRaw + : currentExpiryRaw; const reminderExpiryRaw = isSubscribed ? totalExpiryRaw : currentExpiryRaw || totalExpiryRaw; @@ -921,10 +326,10 @@ export function AccountCenter() { : copy.noProSubscription; const showExpiringSoon = Boolean( isSubscribed && - !hasQueuedExtension && - expiryInfo && - !expiryInfo.expired && - expiryInfo.daysLeft <= 3, + !hasQueuedExtension && + expiryInfo && + !expiryInfo.expired && + expiryInfo.daysLeft <= 3, ); const showExpiredReminder = Boolean( !isSubscribed && expiryInfo && expiryInfo.expired, @@ -932,7 +337,7 @@ export function AccountCenter() { const paymentFeatureReady = paymentReadyForRecovery; const canOpenCheckoutOverlay = Boolean( paymentFeatureReady && - (!isSubscribed || showExpiringSoon || showExpiredReminder), + (!isSubscribed || showExpiringSoon || showExpiredReminder), ); const subscriptionStatusTitle = showExpiredReminder ? copy.proExpiredTitle @@ -954,8 +359,11 @@ export function AccountCenter() { .replace("{days}", String(queuedExtensionDays)) .replace("{total}", totalExpiryFormatted) : ""; - const expiryLabel = hasQueuedExtension ? copy.accessUntil : copy.renewalDate; + const expiryLabel = hasQueuedExtension + ? copy.accessUntil + : copy.renewalDate; + // ── Payment overlay tracking effect ────────────────────── useEffect(() => { if (!showOverlay || !canOpenCheckoutOverlay) return; trackAppEvent("paywall_viewed", { @@ -974,17 +382,7 @@ export function AccountCenter() { showOverlay, ]); - // Points Logic - const backendPointsRaw = Number(backend?.points); - const metadataPointsRaw = Number( - user?.user_metadata?.points ?? user?.user_metadata?.total_points ?? 0, - ); - const metadataPointsSafe = Number.isFinite(metadataPointsRaw) - ? metadataPointsRaw - : 0; - const pointsRaw = Number.isFinite(backendPointsRaw) - ? Math.max(backendPointsRaw, metadataPointsSafe) - : metadataPointsSafe; + // ── Weekly points display (component-only derived) ────── const backendWeeklyPointsRaw = Number(backend?.weekly_points); const metadataWeeklyPointsRaw = Number( user?.user_metadata?.weekly_points ?? 0, @@ -994,150 +392,17 @@ export function AccountCenter() { : metadataWeeklyPointsRaw; const weeklyRankRaw = backend?.weekly_rank ?? user?.user_metadata?.weekly_rank; - const totalPoints = Number.isFinite(pointsRaw) ? Math.max(0, pointsRaw) : 0; const weeklyPoints = Number.isFinite(weeklyPointsRaw) ? Math.max(0, weeklyPointsRaw) : 0; const weeklyRank = weeklyRankRaw == null ? "--" : String(weeklyRankRaw); - const planList = paymentConfig?.plans || []; - const monthlyPlanList = planList.filter( - (plan) => - String(plan.plan_code || "") - .trim() - .toLowerCase() === "pro_monthly", - ); - const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList; - const selectedPlan = - effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || - effectivePlanList[0]; - const availableTokenList: PaymentTokenOption[] = useMemo(() => { - const configured = Array.isArray(paymentConfig?.tokens) - ? paymentConfig?.tokens || [] - : []; - const clean = configured - .filter( - (row) => - row && - typeof row.address === "string" && - row.address.startsWith("0x"), - ) - .map((row) => ({ - ...row, - address: String(row.address).toLowerCase(), - symbol: String(row.symbol || "USDC"), - name: String(row.name || row.symbol || "USDC"), - code: String(row.code || "usdc"), - decimals: Number.isFinite(Number(row.decimals)) - ? Number(row.decimals) - : Number(paymentConfig?.token_decimals ?? 6), - })); - if (clean.length) return clean; - const fallbackAddress = String( - paymentConfig?.token_address || "", - ).toLowerCase(); - if (!fallbackAddress.startsWith("0x")) return []; - return [ - { - code: "usdc", - symbol: "USDC", - name: "USDC", - address: fallbackAddress, - decimals: Number(paymentConfig?.token_decimals ?? 6), - receiver_contract: paymentConfig?.receiver_contract, - is_default: true, - }, - ]; - }, [paymentConfig]); - const resolvedSelectedTokenAddress = String( - selectedTokenAddress || - paymentConfig?.default_token_address || - availableTokenList.find((row) => row.is_default)?.address || - availableTokenList[0]?.address || - paymentConfig?.token_address || - "", - ).toLowerCase(); - const selectedPaymentToken = - availableTokenList.find( - (row) => row.address === resolvedSelectedTokenAddress, - ) || availableTokenList[0]; - const selectedTokenLabel = - selectedPaymentToken?.symbol || - (resolvedSelectedTokenAddress.startsWith("0x") - ? shortAddress(resolvedSelectedTokenAddress) - : "USDC"); - const 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(), - ); - - 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, - ]); - + // ── Telegram bind command ────────────────────────────── const bindCommand = userId ? `/bind ${userId}${email ? ` ${email}` : ""}` : "/bind "; + // ── Copy handler ────────────────────────────────────── const handleCopy = (text: string) => { navigator.clipboard.writeText(text).then(() => { setCopied(true); @@ -1145,962 +410,7 @@ export function AccountCenter() { }); }; - 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); - } - }; - - // --- Payment Logic (preserved) --- - - 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 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], - ); - - const signBindMessage = async ( - eth: EvmProvider, - address: string, - message: string, - ): Promise => { - try { - return (await eth.request({ - method: "personal_sign", - params: [message, address], - })) as string; - } catch { - // Some injected wallets still use the reversed param order. - return (await eth.request({ - method: "personal_sign", - params: [address, message], - })) as string; - } - }; - - const ensureTargetChain = async ( - eth: EvmProvider, - targetChainId: number, - ): Promise => { - const currentChainIdHex = String( - (await 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); - const msg = String(err?.message || "").toLowerCase(); - - // If the error code indicates the chain is not added (4902), or it's Polygon (137) - 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" : "网络切换失败")})`, - ); - } - }; - - 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 (!isAuthenticated) { - 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 - : ""; - - // Ensure we have a valid token BEFORE opening the wallet modal. - let accessToken: string; - try { - accessToken = await getValidAccessToken(); - } catch (tokenErr) { - setPaymentError(normalizePaymentError(tokenErr).message); - setPaymentBusy(false); - return false; - } - const authHeaders: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; - - const accounts = await 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); - } - }; - - const handleUnbindWallet = async (address: string) => { - const target = String(address || "").toLowerCase(); - if (!target) return; - if (!isAuthenticated) { - setPaymentError(copy.loginBeforeBind); - return; - } - const confirmed = window.confirm( - copy.unbindConfirm.replace("{address}", shortAddress(target)), - ); - if (!confirmed) return; - - setPaymentBusy(true); - clearPaymentMessages(); - try { - // Do not hard-fail on client-side token refresh here. - // The same-origin API route can still authenticate via server-side Supabase session cookies. - 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); - } - }; - - const createIntentAndPay = async () => { - clearPaymentMessages(); - clearPaymentState(); - clearStoredPaymentRecovery(); - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || "polyweather-pro.vercel.app", - ), - ); - return; - } - if (!isAuthenticated) { - 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); - - // Ensure we have a valid token BEFORE switching chain / sending tx. - let accessToken: string; - try { - accessToken = await getValidAccessToken(); - } catch (tokenErr) { - setPaymentError(normalizePaymentError(tokenErr).message); - setPaymentBusy(false); - return; - } - const authHeaders: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}`, - }; - - const 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: 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); - } - }; - - const createManualPaymentIntent = async () => { - clearPaymentMessages(); - setManualPayment(null); - setManualTxHash(""); - setLastIntentId(""); - setLastTxHash(""); - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || "polyweather-pro.vercel.app", - ), - ); - return; - } - if (!isAuthenticated) { - 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: 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); - } - }; - - 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); - } - }; - - 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], - ); - - const handleOverlayCheckout = async () => { - if (!paymentHostAllowed) { - setPaymentError( - copy.paymentHostBlocked.replace( - "{host}", - allowedPaymentHosts[0] || "polyweather-pro.vercel.app", - ), - ); - return; - } - if (!isAuthenticated) { - 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(); - }; - - // --- Render --- + // ── Render ──────────────────────────────────────────── if (loading && !refreshing) { return ( @@ -2198,7 +508,7 @@ export function AccountCenter() {

) : null} {billing.canRedeem ? ( -

+

当前可用 {billing.pointsUsed} 积分抵扣 $ {billing.discountAmount.toFixed(2)}, 续费时会自动生效。

@@ -2236,7 +546,9 @@ export function AccountCenter() { > {isSubscribed ? copy.proMember - : isEn ? "UNSUBSCRIBED" : "未订阅"} + : isEn + ? "UNSUBSCRIBED" + : "未订阅"}

@@ -2462,80 +774,88 @@ export function AccountCenter() {

{canAccessPaidTelegramGroup && (
- -
-

- {copy.telegramBind} -

-

- {copy.telegramHint} -

+ +
+

+ {copy.telegramBind} +

+

+ {copy.telegramHint} +

-
- {TELEGRAM_TOPICS_GROUP_URL && - TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL && - telegramBound ? ( - + {TELEGRAM_TOPICS_GROUP_URL && + TELEGRAM_TOPICS_GROUP_URL !== TELEGRAM_GROUP_URL && + telegramBound ? ( + + {copy.telegramTopicsGroupLink} + + + ) : null} + {TELEGRAM_GROUP_URL && telegramBound ? ( + + {copy.telegramGroupLink} + + + ) : null} +
+
+ + {bindCommand} + + +
+

+ {copy.telegramFallbackHint} +

+
+ {copy.paymentManualSupport} +
-
- - {bindCommand} - - - -
-

- {copy.telegramFallbackHint} -

-
- {copy.paymentManualSupport} -
-
-
+ )} {/* Payment Details / Wallet Management */} -
+

{copy.paymentMgmt} @@ -2564,7 +884,8 @@ export function AccountCenter() {
{copy.paymentHostBlocked.replace( "{host}", - allowedPaymentHosts[0] || "polyweather-pro.vercel.app", + allowedPaymentHosts[0] || + "polyweather-pro.vercel.app", )}
) : null} @@ -2608,13 +929,16 @@ export function AccountCenter() { {availableTokenList.map((token) => { const active = token.address === - (resolvedSelectedTokenAddress || token.address); + (resolvedSelectedTokenAddress || + token.address); return (

)} - {/* 支付方式选择 Tabs */} + {/* Payment Method Tabs */}

{copy.paymentMethodLabel} @@ -2676,7 +1000,7 @@ export function AccountCenter() {

{copy.paymentGasWarning}
- + {boundWallets.length ? (
{boundWallets.map((w) => ( @@ -2698,11 +1022,17 @@ export function AccountCenter() { )}
-
{copy.polygonChain}
+
+ {copy.polygonChain} +
{!walletConnectEnabled && (

@@ -2784,7 +1126,7 @@ export function AccountCenter() {

{copy.paymentManualDesc}

- +
@@ -2797,8 +1139,12 @@ export function AccountCenter() {
@@ -2821,12 +1168,16 @@ export function AccountCenter() {

- {manualPayment.receiver_address} + { + manualPayment.receiver_address + }
); } - diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 31e7e7fc..f68265fc 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -44,12 +44,26 @@ export function runTests() { ); const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8"); + const hookPath = path.join( + projectRoot, + "components", + "account", + "useAccountPayment.ts", + ); + const hookSource = fs.existsSync(hookPath) + ? fs.readFileSync(hookPath, "utf8") + : ""; + + // The receiver validation now lives in the extracted hook file (called + // from createManualPaymentIntent and createIntentAndPay). assert( - accountCenterSource.includes("assertExpectedPaymentReceiver"), + accountCenterSource.includes("assertExpectedPaymentReceiver") || + hookSource.includes("assertExpectedPaymentReceiver"), "AccountCenter must validate backend-returned manual payment receiver before displaying it", ); assert( - accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"), + accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || + hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"), "AccountCenter must show the pinned payment receiver address in its payment guard", ); diff --git a/frontend/components/account/__tests__/paymentShell.test.ts b/frontend/components/account/__tests__/paymentShell.test.ts index 2a6c2ae8..14473880 100644 --- a/frontend/components/account/__tests__/paymentShell.test.ts +++ b/frontend/components/account/__tests__/paymentShell.test.ts @@ -51,10 +51,17 @@ export function runTests() { accountCenterSource.includes('const copy = useMemo(() => createAccountCopy(isEn), [isEn]);'), "AccountCenter copy text must be centralized in account-copy.ts instead of an inline 170+ line object", ); + const hookPath = path.join(accountDir, "useAccountPayment.ts"); + const hookSource = fs.existsSync(hookPath) + ? fs.readFileSync(hookPath, "utf8") + : ""; assert( - accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') && - accountCenterSource.includes("clearPaymentState") && - accountCenterSource.includes("clearPaymentMessages"), + (accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') || + hookSource.includes('import { usePaymentState } from "./usePaymentState";')) && + (accountCenterSource.includes("clearPaymentState") || + hookSource.includes("clearPaymentState")) && + (accountCenterSource.includes("clearPaymentMessages") || + hookSource.includes("clearPaymentMessages")), "payment UI state reset/message helpers must be centralized in usePaymentState.ts", ); const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8"); diff --git a/frontend/components/account/useAccountPayment.ts b/frontend/components/account/useAccountPayment.ts new file mode 100644 index 00000000..986ba6a5 --- /dev/null +++ b/frontend/components/account/useAccountPayment.ts @@ -0,0 +1,2394 @@ +"use client"; + +import { useCallback, useEffect, useMemo, 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, +} 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"; + +// ============================================================ +export interface UseAccountPaymentParams { + isEn: boolean; + supabaseReady: boolean; + walletConnectEnabled: boolean; + copy: Record; + backend: AuthMeResponse | null; + user: User | null; + setUser: (user: User | null) => void; + setBackend: (backend: AuthMeResponse | null) => void; + setErrorText: (text: string) => void; + setUpdatedAt: (text: string) => void; + showOverlay: boolean; + setShowOverlay: (v: boolean) => void; + usePoints: boolean; + setUsePoints: (v: boolean) => void; +} + +// ============================================================ +export function useAccountPayment(params: UseAccountPaymentParams) { + const { + isEn, + supabaseReady, + walletConnectEnabled, + copy, + backend, + user, + setUser, + setBackend, + setErrorText, + setUpdatedAt, + showOverlay, + setShowOverlay, + usePoints, + setUsePoints, + } = params; + + // ── Base payment state (from usePaymentState) ────────────── + const { + paymentBusy, + setPaymentBusy, + paymentInfo, + setPaymentInfo, + paymentError, + setPaymentError, + lastIntentId, + setLastIntentId, + lastTxHash, + setLastTxHash, + telegramBindOpening, + setTelegramBindOpening, + manualPayment, + setManualPayment, + manualTxHash, + setManualTxHash, + txValidation, + setTxValidation, + paymentMethodTab, + setPaymentMethodTab, + lastPaymentStartedAt, + setLastPaymentStartedAt, + clearPaymentMessages, + 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 => { + if (!supabaseReady) + throw new Error( + isEn + ? "Supabase is not configured. Unable to get auth token." + : "Supabase 未配置,无法获取登录凭证。", + ); + const client = getSupabaseBrowserClient(); + 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 + ) { + return cachedToken; + } + if ( + cachedToken && + (!Number.isFinite(expiresAtSec) || expiresAtSec <= 0) + ) { + return cachedToken; + } + 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 + ) { + return cachedToken; + } + throw new Error( + error?.message + ? isEn + ? `Session expired (${error.message}). Please sign out and sign in again.` + : `登录会话已失效 (${error.message}),请退出后重新登录。` + : isEn + ? "Session expired. Please sign out and sign in again." + : "登录会话已失效,请退出后重新登录。", + ); + }, [isEn, supabaseReady]); + + // ── buildAuthedHeaders ────────────────────────────────── + const buildAuthedHeaders = useCallback( + async ( + withJson = false, + requireAuth = false, + ): Promise> => { + const headers: Record = {}; + if (withJson) headers["Content-Type"] = "application/json"; + if (!supabaseReady) return headers; + try { + const token = await getValidAccessToken(); + headers.Authorization = `Bearer ${token}`; + } catch (error) { + if (requireAuth) throw error; + try { + const { + data: { session }, + } = await getSupabaseBrowserClient().auth.getSession(); + const fallbackToken = String(session?.access_token || "").trim(); + if (fallbackToken) { + headers.Authorization = `Bearer ${fallbackToken}`; + } + } catch { + // Non-authenticated page load — silently skip. + } + } + return headers; + }, + [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, + ); + const existingAccounts = (await wcProvider + .request({ method: "eth_accounts" }) + .catch(() => [])) as string[]; + if ( + !Array.isArray(existingAccounts) || + existingAccounts.length === 0 + ) { + if (typeof wcProvider.connect === "function") { + try { + await wcProvider.connect({ chains: [targetChainId] }); + } catch (err) { + if (!isWalletConnectResetError(err)) throw err; + await resetWalletConnectProvider(); + const freshProvider = await getWalletConnectProvider( + targetChainId, + WALLETCONNECT_POLYGON_RPC_URL, + ); + if (typeof freshProvider.connect === "function") { + await freshProvider.connect({ chains: [targetChainId] }); + } + return { + provider: freshProvider, + label: "WalletConnect", + mode: "walletconnect", + }; + } + } + } + return { + provider: wcProvider, + label: "WalletConnect", + mode: "walletconnect", + }; + }, + [paymentConfig?.chain_id, walletConnectEnabled], + ); + + // ── loadPaymentSnapshot ────────────────────────────────── + const loadPaymentSnapshot = useCallback(async () => { + if (!backend?.authenticated) { + setPaymentConfig(null); + setBoundWallets([]); + return; + } + try { + const authHeadersPromise = buildAuthedHeaders(false); + const [configRes, walletsRes] = await Promise.all([ + authHeadersPromise.then((headers) => + fetch("/api/payments/config", { cache: "no-store", headers }), + ), + authHeadersPromise.then((headers) => + fetch("/api/payments/wallets", { cache: "no-store", headers }), + ), + ]); + if (configRes.ok) { + const configJson = (await configRes.json()) as PaymentConfig; + setPaymentConfig(configJson); + if (!selectedPlanCode && configJson.plans?.length) { + setSelectedPlanCode(configJson.plans[0].plan_code); + } + const tokenOptions = Array.isArray(configJson.tokens) + ? configJson.tokens.filter( + (row) => + typeof row?.address === "string" && + String(row.address).startsWith("0x"), + ) + : []; + const defaultTokenAddress = String( + configJson.default_token_address || + tokenOptions.find((row) => row.is_default)?.address || + tokenOptions[0]?.address || + configJson.token_address || + "", + ).toLowerCase(); + if (defaultTokenAddress) { + setSelectedTokenAddress( + (prev) => prev || defaultTokenAddress, + ); + } + } + if (walletsRes.ok) { + const walletsJson = (await walletsRes.json()) as { + wallets?: BoundWallet[]; + }; + const wallets = ( + Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [] + ) + .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(), + })); + 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); + } + } else { + setSelectedWallet(""); + setWalletAddress(""); + } + } + } catch { + // ignore + } + }, [ + backend?.authenticated, + buildAuthedHeaders, + selectedPlanCode, + selectedWallet, + 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], + ); + + // ── 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]); + + // ── 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]); + + // ── 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, + 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.orderAlreadyPaid, + copy.orderExpired, + copy.submitTxFailed, + loadPaymentSnapshot, + loadSnapshot, + reconcileLatestPayment, + ], + ); + + // ── 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, + ); + } + })(); + }, [ + authIsAuthenticated, + buildAuthedHeaders, + loadPaymentSnapshot, + loadSnapshot, + ]); + + // ========================================================== + return { + // State from usePaymentState + paymentBusy, + paymentInfo, + paymentError, + lastIntentId, + lastTxHash, + lastPaymentStartedAt, + telegramBindOpening, + telegramBindUrl, + manualPayment, + manualTxHash, + txValidation, + paymentMethodTab, + clearPaymentMessages, + clearPaymentState, + + // Setters from usePaymentState (needed by component render) + setPaymentBusy, + setPaymentInfo, + setPaymentError, + setLastIntentId, + setLastTxHash, + setLastPaymentStartedAt, + setTelegramBindOpening, + setPaymentMethodTab, + setManualPayment, + setManualTxHash, + setTxValidation, + + // Additional state + paymentConfig, + boundWallets, + walletAddress, + selectedPlanCode, + selectedTokenAddress, + selectedWallet, + providerMode, + injectedProviderOptions, + selectedInjectedProviderKey, + reconcileBusy, + + // Setters for shared state + setSelectedTokenAddress, + setSelectedWallet, + setSelectedInjectedProviderKey, + setProviderMode, + + // Derived values + authUserId, + authIsAuthenticated, + paymentReadyForRecovery, + hasRecentPaymentRecovery, + allowedPaymentHosts, + currentPaymentHost, + paymentHostAllowed, + selectedPlan, + selectedPaymentToken, + selectedTokenLabel, + availableTokenList, + effectivePlanList, + resolvedSelectedTokenAddress, + paymentReceiverAddress, + paymentWalletLabel, + hasPayingWallet, + totalPoints, + billing, + + // Callbacks + loadSnapshot, + loadPaymentSnapshot, + connectAndBindWallet, + handleUnbindWallet, + createIntentAndPay, + createManualPaymentIntent, + submitManualPaymentTx, + validateTxHash, + handleOverlayCheckout, + openTelegramBotBindLink, + }; +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts index d772c645..95713021 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/removedMonitorRunwayTabs.test.ts @@ -43,10 +43,9 @@ export function runTests() { "scan terminal content views must not include market monitor or runway tabs", ); assert( - shellPartsSource.includes('"city-list"') && - dashboardSource.includes("MarketTable") && + dashboardSource.includes("MarketTable") && dashboardSource.includes("decisionLabel"), - "scan terminal must expose city-list type and new terminal layout components", + "scan terminal must expose new terminal layout components (MarketTable, decisionLabel)", ); assert( !dashboardSource.includes('setActiveView("monitor")') &&