From 3534f759d0c723bed82d141020243ab391e7fbcc Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Mon, 25 May 2026 02:14:21 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=88=E7=AB=AF=E5=86=85=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=BC=B9=E5=87=BA=20UnlockProOverlay=EF=BC=9A=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=94=AF=E4=BB=98=E6=97=A0=E9=9C=80=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 useTerminalPay hook 和 billing-utils 纯函数,SubscriptionGate 集成支付弹窗。 用户点击订阅后原地弹窗 → 连接钱包 → 链上支付 → 自动刷新进入终端。 --- .../dashboard/ScanTerminalDashboard.tsx | 131 +++++- .../components/subscription/useTerminalPay.ts | 418 ++++++++++++++++++ frontend/lib/billing-utils.ts | 69 +++ 3 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 frontend/components/subscription/useTerminalPay.ts create mode 100644 frontend/lib/billing-utils.ts diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 23a63cf9..522fe953 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -20,7 +20,7 @@ import { Table2, UserRound, } from "lucide-react"; -import { Fragment, useEffect, useMemo, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { Area, AreaChart, @@ -36,6 +36,8 @@ import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; +import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay"; +import { useTerminalPay } from "@/components/subscription/useTerminalPay"; import { type ContinentGroup, buildContinentGroups, @@ -888,7 +890,42 @@ function KoyfinWeatherTerminal({ // ─── Layer 2: Authenticated but no active subscription ─────────────────────── // Shown inside the terminal shell after middleware has confirmed the user is // logged in (Layer 1). Presents a targeted upgrade prompt. -function SubscriptionGate({ isEn }: { isEn: boolean }) { +function SubscriptionGate({ + isEn, + points, + onPaid, +}: { + isEn: boolean; + points: number; + onPaid: () => void; +}) { + const [showOverlay, setShowOverlay] = useState(false); + const { + billing, + usePoints, + setUsePoints, + payBusy, + errorText, + infoText, + txHash, + chainId, + tokenSymbol, + walletAddress, + handlePay, + } = useTerminalPay({ + points, + planPriceUsd: 10, + onPaid: () => { + setShowOverlay(false); + onPaid(); + }, + isEn, + }); + + const handleSubscribeClick = () => { + setShowOverlay(true); + }; + const features = isEn ? [ "Real-time METAR observations across 500+ stations", @@ -954,13 +991,14 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) { {/* CTA */} - {t("subscribeNow", isEn)} - +

{isEn @@ -980,6 +1018,38 @@ function SubscriptionGate({ isEn }: { isEn: boolean }) { + + {/* Payment overlay */} + {showOverlay && ( +

+ setUsePoints((prev) => !prev)} + onPay={() => void handlePay()} + onClose={() => setShowOverlay(false)} + payBusy={payBusy} + payLabel={ + walletAddress + ? isEn + ? "Subscribe & Activate" + : "立即订阅并激活服务" + : isEn + ? "Connect Wallet & Subscribe" + : "连接钱包并订阅" + } + errorText={errorText || undefined} + infoText={infoText || undefined} + txHash={txHash || undefined} + chainId={chainId} + paymentTokenLabel={tokenSymbol} + locale={isEn ? "en-US" : "zh-CN"} + faqHref="/subscription-help" + /> +
+ )} ); } @@ -1047,17 +1117,21 @@ function UnauthenticatedGate({ function ProductAccessRequired({ isAuthenticated, isEn, + points, + onPaid, userLocalTime, }: { isAuthenticated: boolean; isEn: boolean; + points: number; + onPaid: () => void; userLocalTime: string; }) { // Layer 1 fallback (middleware should catch this first in production) if (!isAuthenticated) { return ; } - // Layer 2: logged in, no subscription + // Layer 2: logged in, no subscription — inline pay overlay return (
); @@ -1187,11 +1261,54 @@ function ScanTerminalScreen() { ); } + const refreshAuth = useCallback(() => { + fetch("/api/auth/me", { + cache: "no-store", + headers: { Accept: "application/json" }, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise<{ + authenticated?: boolean; + user_id?: string | null; + subscription_active?: boolean | null; + subscription_plan_code?: string | null; + subscription_expires_at?: string | null; + subscription_total_expires_at?: string | null; + subscription_queued_days?: number | null; + points?: number | null; + }>; + }) + .then((payload) => { + setProAccess({ + loading: false, + authenticated: Boolean(payload.authenticated), + userId: payload.user_id ?? null, + subscriptionActive: payload.subscription_active === true, + subscriptionPlanCode: payload.subscription_plan_code ?? null, + subscriptionExpiresAt: payload.subscription_expires_at ?? null, + subscriptionTotalExpiresAt: + payload.subscription_total_expires_at ?? + payload.subscription_expires_at ?? + null, + subscriptionQueuedDays: Math.max( + 0, + Number(payload.subscription_queued_days ?? 0), + ), + points: Number(payload.points ?? 0), + error: null, + }); + }) + .catch(() => {}); // keep current state on error + }, []); + if (!isAuthenticated || !isPro) { return ( ); diff --git a/frontend/components/subscription/useTerminalPay.ts b/frontend/components/subscription/useTerminalPay.ts new file mode 100644 index 00000000..f0452cce --- /dev/null +++ b/frontend/components/subscription/useTerminalPay.ts @@ -0,0 +1,418 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { UnlockProBilling } from "@/components/subscription/UnlockProOverlay"; +import { computeBilling } from "@/lib/billing-utils"; +import { + WALLETCONNECT_POLYGON_RPC_URL, + WALLET_TRANSACTION_REQUEST_TIMEOUT_MS, +} from "@/components/account/constants"; +import { + buildAllowanceCalldata, + buildApproveCalldata, + buildBalanceOfCalldata, + requestWalletWithTimeout, +} from "@/components/account/payment-utils"; +import { isPaymentHostAllowed, getCurrentPaymentHost } from "@/lib/payment-host"; +import { + getEvmProvider, + getEvmWalletLabel, + getWalletConnectProvider, +} from "@/components/account/wallet"; +import { getSupabaseBrowserClient } from "@/lib/supabase/client"; +import type { EvmProvider, CreatedIntent } from "@/components/account/types"; + +const DEFAULT_CHAIN_ID = 137; +const INTENT_POLL_INTERVAL_MS = 5000; +const INTENT_POLL_TIMEOUT_MS = 180_000; + +function normalizePaymentError(err: unknown, fallback: string): string { + if (!err) return fallback; + const e = err as Record; + return String( + e.shortMessage || e.message || e.reason || e.error || fallback, + ); +} + +export function useTerminalPay(params: { + points: number; + planPriceUsd: number; + onPaid: () => void; + isEn: boolean; +}) { + const { points, planPriceUsd, onPaid, isEn } = params; + + const [paymentConfig, setPaymentConfig] = useState | null>(null); + const [walletAddress, setWalletAddress] = useState(null); + const [usePoints, setUsePoints] = useState(true); + const [payBusy, setPayBusy] = useState(false); + const [errorText, setErrorText] = useState(""); + const [infoText, setInfoText] = useState(""); + const [txHash, setTxHash] = useState(""); + + useEffect(() => { + let cancelled = false; + fetch("/api/payments/config", { + cache: "no-store", + headers: { Accept: "application/json" }, + }) + .then(async (r) => { + if (!r.ok) return null; + return r.json(); + }) + .then((cfg) => { + if (cancelled) return; + setPaymentConfig(cfg || null); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const billing: UnlockProBilling = useMemo(() => { + const raw = computeBilling({ + planPriceUsd, + totalPoints: points, + usePoints, + redemptionCfg: (paymentConfig?.points_redemption as Record) ?? null, + }); + return { + pointsEnabled: raw.pointsEnabled, + isEligible: raw.canRedeem, + pointsUsed: raw.pointsUsed, + discountAmount: raw.discountAmount, + finalPrice: raw.payAmount, + maxDiscountUsd: raw.maxDiscountUsdc, + pointsPerUsd: raw.pointsPerUsdc, + }; + }, [planPriceUsd, points, usePoints, paymentConfig]); + + const chainId = useMemo( + () => Number(paymentConfig?.chain_id || DEFAULT_CHAIN_ID), + [paymentConfig], + ); + + const tokenAddress = useMemo(() => { + const tokens = (paymentConfig?.tokens as Array>) || []; + return String(tokens[0]?.address || ""); + }, [paymentConfig]); + + const tokenSymbol = useMemo(() => { + const tokens = (paymentConfig?.tokens as Array>) || []; + return String(tokens[0]?.symbol || "USDC"); + }, [paymentConfig]); + + const tokenDecimals = useMemo(() => { + const tokens = (paymentConfig?.tokens as Array>) || []; + return Number(tokens[0]?.decimals || 6); + }, [paymentConfig]); + + const receiverAddress = useMemo( + () => String(paymentConfig?.receiver_address || ""), + [paymentConfig], + ); + + const connectWallet = useCallback(async (): Promise => { + try { + let provider: EvmProvider | null = getEvmProvider(); + let label = getEvmWalletLabel(provider); + + if (!provider) { + const wcId = String( + process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "", + ).trim(); + if (!wcId) { + setErrorText( + isEn + ? "No wallet detected. Install MetaMask or configure WalletConnect." + : "未检测到钱包插件,请安装 MetaMask 扩展。", + ); + return null; + } + provider = await getWalletConnectProvider( + chainId, + WALLETCONNECT_POLYGON_RPC_URL, + ); + label = "WalletConnect"; + const existing = (await provider + .request({ method: "eth_accounts" }) + .catch(() => [])) as string[]; + if (!Array.isArray(existing) || existing.length === 0) { + if (typeof provider.connect === "function") { + await provider.connect({ chains: [chainId] }); + } + } + } + + const accounts = (await provider.request({ + method: "eth_requestAccounts", + })) as string[]; + const address = String(accounts[0] || "").toLowerCase(); + if (!address) throw new Error("No account returned"); + + setWalletAddress(address); + return address; + } catch (err) { + setErrorText(normalizePaymentError(err, isEn ? "Wallet connection failed" : "钱包连接失败")); + return null; + } + }, [chainId, isEn]); + + const handlePay = useCallback(async () => { + if (payBusy) return; + setPayBusy(true); + setErrorText(""); + setInfoText(""); + + try { + const host = getCurrentPaymentHost(); + const allowed = await isPaymentHostAllowed(host); + if (!allowed) throw new Error(isEn ? "Payment host not allowed" : "当前域名不在支付白名单中"); + + // Get session token + const supabase = getSupabaseBrowserClient(); + const { data: sessionData } = await supabase.auth.getSession(); + const token = sessionData?.session?.access_token; + if (!token) throw new Error(isEn ? "Session expired, please refresh" : "会话过期,请刷新页面"); + const authHeaders = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + + // Connect wallet + let payingWallet = walletAddress; + if (!payingWallet) { + payingWallet = await connectWallet(); + if (!payingWallet) { + setPayBusy(false); + return; + } + } + + // Refresh payment config + const cfgRes = await fetch("/api/payments/config", { + headers: { Accept: "application/json" }, + }); + const latestCfg = cfgRes.ok ? await cfgRes.json() : paymentConfig; + setPaymentConfig(latestCfg || paymentConfig); + + const targetChainId = Number(latestCfg?.chain_id || DEFAULT_CHAIN_ID); + + // Resolve provider for payment + let eth = getEvmProvider(); + if (!eth) { + const wcId = String(process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "").trim(); + if (!wcId) throw new Error("No wallet available"); + eth = await getWalletConnectProvider(targetChainId, WALLETCONNECT_POLYGON_RPC_URL); + const existing = (await eth.request({ method: "eth_accounts" }).catch(() => [])) as string[]; + if (!Array.isArray(existing) || existing.length === 0) { + if (typeof eth.connect === "function") { + await eth.connect({ chains: [targetChainId] }); + } + } + } + + // Ensure target chain + const currentChainHex = String( + (await requestWalletWithTimeout(eth, { method: "eth_chainId" }, "chainId", WALLET_TRANSACTION_REQUEST_TIMEOUT_MS)) || "", + ); + const targetHex = `0x${targetChainId.toString(16)}`; + if (currentChainHex.toLowerCase() !== targetHex.toLowerCase()) { + try { + await requestWalletWithTimeout(eth, { + method: "wallet_switchEthereumChain", + params: [{ chainId: targetHex }], + }, "switchChain"); + } catch (switchErr: unknown) { + const code = Number((switchErr as Record)?.code); + if (code === 4902 || targetChainId === 137) { + 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"], + }], + }, "addChain"); + } else { + throw switchErr; + } + } + } + + // Create payment intent + const resolvedToken = String(latestCfg?.tokens?.[0]?.address || tokenAddress); + const intentRes = await fetch("/api/payments/intents", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + plan_code: "pro_monthly", + payment_mode: "strict", + allowed_wallet: payingWallet, + token_address: resolvedToken || undefined, + use_points: billing.isEligible && usePoints, + points_to_consume: billing.isEligible && usePoints ? billing.pointsUsed : 0, + metadata: { + source: "terminal", + frontend_host: host || null, + }, + }), + }); + if (!intentRes.ok) { + const errBody = await intentRes.json().catch(() => ({})); + throw new Error(String(errBody?.detail || errBody?.message || `HTTP ${intentRes.status}`)); + } + const created = (await intentRes.json()) as CreatedIntent; + const intentId = String(created.intent?.intent_id || ""); + const txPayload = created.tx_payload; + if (!intentId || !txPayload?.to || !txPayload?.data) { + throw new Error(isEn ? "Invalid payment intent response" : "支付意图创建失败"); + } + + // Verify receiver + const expectedReceiver = String(latestCfg?.receiver_address || receiverAddress); + if (expectedReceiver && txPayload.to.toLowerCase() !== expectedReceiver.toLowerCase()) { + throw new Error(isEn ? "Payment receiver mismatch" : "收款地址不匹配"); + } + + const resolvedTokenDecimals = Number( + latestCfg?.tokens?.[0]?.decimals || tokenDecimals, + ); + const amountUnits = + typeof txPayload.amount_units === "string" + ? BigInt(txPayload.amount_units) + : BigInt(String(txPayload.value || 0)); + + // Check balance + const balanceHex = await requestWalletWithTimeout( + eth, + { + method: "eth_call", + params: [ + { to: txPayload.token_address || resolvedToken, data: buildBalanceOfCalldata(payingWallet) }, + "latest", + ], + }, + "balanceOf", + ); + const balance = BigInt(balanceHex || "0x0"); + if (balance < amountUnits) { + throw new Error(isEn ? "Insufficient USDC balance" : "USDC 余额不足"); + } + + // Check allowance and approve if needed + const allowanceHex = await requestWalletWithTimeout( + eth, + { + method: "eth_call", + params: [ + { to: txPayload.token_address || resolvedToken, data: buildAllowanceCalldata(payingWallet, txPayload.to) }, + "latest", + ], + }, + "allowance", + ); + const allowance = BigInt(allowanceHex || "0x0"); + if (allowance < amountUnits) { + setInfoText(isEn ? "Approving USDC..." : "正在授权 USDC..."); + const approveHash = await requestWalletWithTimeout( + eth, + { + method: "eth_sendTransaction", + params: [{ + from: payingWallet, + to: txPayload.token_address || resolvedToken, + data: buildApproveCalldata(txPayload.to, amountUnits), + }], + }, + "approve", + ); + // Wait for approval receipt via simple polling + const approveStart = Date.now(); + while (Date.now() - approveStart < 120_000) { + const receipt = await requestWalletWithTimeout | null>( + eth, + { method: "eth_getTransactionReceipt", params: [approveHash] }, + "approveReceipt", + ).catch(() => null); + if (receipt?.status === "0x1" || receipt?.status === 1) break; + if (receipt?.status === "0x0" || receipt?.status === 0) { + throw new Error(isEn ? "USDC approval failed" : "USDC 授权失败"); + } + await new Promise((r) => setTimeout(r, 3000)); + } + } + + // Send payment transaction + setInfoText(isEn ? "Confirm in wallet..." : "请在钱包中确认交易..."); + const payHash = await requestWalletWithTimeout( + eth, + { + method: "eth_sendTransaction", + params: [{ from: payingWallet, to: txPayload.to, data: txPayload.data }], + }, + "pay", + ); + setTxHash(payHash); + + // Submit + const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ tx_hash: payHash, from_address: payingWallet }), + }); + if (!submitRes.ok && submitRes.status !== 409) { + const errBody = await submitRes.json().catch(() => ({})); + throw new Error(String(errBody?.detail || `HTTP ${submitRes.status}`)); + } + + // Confirm + const confirmStart = Date.now(); + let confirmed = false; + while (Date.now() - confirmStart < INTENT_POLL_TIMEOUT_MS) { + const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ tx_hash: payHash }), + }); + if (confirmRes.ok) { + setInfoText(isEn ? "Payment confirmed! Activating..." : "支付确认!正在激活..."); + confirmed = true; + break; + } + if (confirmRes.status === 409 || confirmRes.status === 404 || confirmRes.status === 408) { + await new Promise((r) => setTimeout(r, INTENT_POLL_INTERVAL_MS)); + continue; + } + const errBody = await confirmRes.json().catch(() => ({})); + throw new Error(String(errBody?.detail || `HTTP ${confirmRes.status}`)); + } + if (!confirmed) { + throw new Error(isEn ? "Payment confirmation timeout" : "支付确认超时,请稍后检查订单状态"); + } + + setInfoText(isEn ? "Subscription activated!" : "订阅已激活!"); + onPaid(); + } catch (err) { + setErrorText(normalizePaymentError(err, isEn ? "Payment failed" : "支付失败")); + } finally { + setPayBusy(false); + } + }, [ + payBusy, walletAddress, paymentConfig, billing, usePoints, chainId, + tokenAddress, tokenDecimals, receiverAddress, isEn, connectWallet, onPaid, + ]); + + return { + billing, + usePoints, + setUsePoints, + payBusy, + errorText, + infoText, + txHash, + chainId, + tokenSymbol, + walletAddress, + connectWallet, + handlePay, + }; +} diff --git a/frontend/lib/billing-utils.ts b/frontend/lib/billing-utils.ts new file mode 100644 index 00000000..afc1e3a3 --- /dev/null +++ b/frontend/lib/billing-utils.ts @@ -0,0 +1,69 @@ +export interface TerminalBilling { + planAmount: number; + pointsEnabled: boolean; + pointsPerUsdc: number; + maxDiscountUsdc: number; + pointsUsed: number; + discountAmount: number; + payAmount: number; + canRedeem: boolean; +} + +export interface PointsRedemptionConfig { + enabled?: boolean; + points_per_usdc?: number; + max_discount_usdc?: number; +} + +export function computeBilling(params: { + planPriceUsd: number; + totalPoints: number; + usePoints: boolean; + redemptionCfg?: PointsRedemptionConfig | null; +}): TerminalBilling { + const planAmount = + Number.isFinite(params.planPriceUsd) && params.planPriceUsd > 0 + ? params.planPriceUsd + : 10; + + const cfg = params.redemptionCfg || {}; + const pointsEnabled = cfg.enabled !== false; + const pointsPerUsdcRaw = Number(cfg.points_per_usdc ?? 500); + const pointsPerUsdc = + Number.isFinite(pointsPerUsdcRaw) && pointsPerUsdcRaw > 0 + ? Math.floor(pointsPerUsdcRaw) + : 500; + + const maxDiscountRaw = Number(cfg.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(params.totalPoints, maxRedeemablePoints) + : 0; + const discountUnits = Math.floor(actualRedeem / pointsPerUsdc); + const pointsUsed = discountUnits * pointsPerUsdc; + const canRedeem = + pointsEnabled && + maxDiscountUsdc > 0 && + params.totalPoints >= pointsPerUsdc; + const applyDiscount = + params.usePoints && canRedeem && pointsUsed > 0; + + return { + planAmount, + pointsEnabled, + pointsPerUsdc, + maxDiscountUsdc, + pointsUsed, + discountAmount: discountUnits, + payAmount: planAmount - (applyDiscount ? discountUnits : 0), + canRedeem, + }; +}