"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, }; }