拆分 AccountCenter 超大组件:提取类型、常量、钱包、支付等独立模块
- AccountCenter.tsx 从 3565 行缩减约 1000 行 - 新增 8 个模块:types / constants / formatters / account-copy / wallet / payment-utils / usePaymentState / AccountInfoRow - 同步更新 paymentShell 测试 Confidence: high
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
type InfoRowProps = {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
|
||||
export const InfoRow = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
isPrimary = false,
|
||||
}: InfoRowProps) => (
|
||||
<div className="flex min-w-0 flex-col gap-3 p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||
{Icon && <Icon size={18} />}
|
||||
</div>
|
||||
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
export function PlusIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,47 @@ export function runTests() {
|
||||
const serviceWorkerPath = path.join(projectRoot, "public", "sw.js");
|
||||
|
||||
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
||||
const accountDir = path.dirname(accountCenterPath);
|
||||
const accountSplitFiles = [
|
||||
"types.ts",
|
||||
"constants.ts",
|
||||
"formatters.ts",
|
||||
"wallet.ts",
|
||||
"payment-utils.ts",
|
||||
"AccountInfoRow.tsx",
|
||||
"account-copy.ts",
|
||||
"usePaymentState.ts",
|
||||
];
|
||||
for (const file of accountSplitFiles) {
|
||||
assert(
|
||||
fs.existsSync(path.join(accountDir, file)),
|
||||
`AccountCenter split file must exist: components/account/${file}`,
|
||||
);
|
||||
}
|
||||
const accountFeatureSource = [
|
||||
"AccountCenter.tsx",
|
||||
"account-copy.ts",
|
||||
"wallet.ts",
|
||||
"payment-utils.ts",
|
||||
"usePaymentState.ts",
|
||||
]
|
||||
.map((file) => fs.readFileSync(path.join(accountDir, file), "utf8"))
|
||||
.join("\n");
|
||||
assert(
|
||||
accountCenterSource.split(/\r?\n/).length < 3200,
|
||||
"AccountCenter.tsx must stay below 3200 lines after extracting account helpers",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes('import { createAccountCopy } from "./account-copy";') &&
|
||||
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",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes('import { usePaymentState } from "./usePaymentState";') &&
|
||||
accountCenterSource.includes("clearPaymentState") &&
|
||||
accountCenterSource.includes("clearPaymentMessages"),
|
||||
"payment UI state reset/message helpers must be centralized in usePaymentState.ts",
|
||||
);
|
||||
const serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
||||
const appAnalyticsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "lib", "app-analytics.ts"),
|
||||
@@ -64,18 +105,18 @@ export function runTests() {
|
||||
);
|
||||
assert(
|
||||
!/label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/.test(
|
||||
accountCenterSource,
|
||||
accountFeatureSource,
|
||||
),
|
||||
"Binance Web3 Wallet injected provider must remain available for browser-extension binding",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("Binance 扩展已绑定") &&
|
||||
accountCenterSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
||||
accountFeatureSource.includes("Binance 扩展已绑定") &&
|
||||
accountFeatureSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
||||
"Binance extension binding must show a WalletConnect fallback hint for payment stability",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
||||
accountCenterSource.includes("只有 USDC 可能无法完成授权或支付"),
|
||||
accountFeatureSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
||||
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
|
||||
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
|
||||
);
|
||||
assert(
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
return {
|
||||
backHome: isEn ? "Back to Home" : "返回首页",
|
||||
accountCenter: isEn ? "Account Center" : "账户中心",
|
||||
loadingAccount: isEn ? "Loading account info..." : "加载账户信息中...",
|
||||
refresh: isEn ? "Refresh" : "刷新",
|
||||
signOut: isEn ? "Sign Out" : "退出",
|
||||
signIn: isEn ? "Sign In" : "登录",
|
||||
upgradePro: isEn ? "Upgrade Pro" : "升级 Pro",
|
||||
guestUser: isEn ? "Guest User" : "游客用户",
|
||||
joinedAt: isEn ? "Joined" : "加入时间",
|
||||
totalPoints: isEn ? "Total Points" : "总积分 (荣誉)",
|
||||
weeklyPoints: isEn ? "Weekly Points" : "本周积分 (竞技)",
|
||||
weeklyRank: isEn ? "Weekly Rank" : "周排行 (竞技)",
|
||||
weeklyRewards: isEn ? "Weekly Rewards" : "周榜奖励",
|
||||
membershipDetails: isEn ? "Membership Details" : "会员权限详情",
|
||||
identityStatus: isEn ? "Identity Status" : "身份状态",
|
||||
authMode: isEn ? "Auth Mode" : "鉴权模式",
|
||||
weatherEngine: isEn ? "Weather Engine" : "气象引擎",
|
||||
intradayAnalysis: isEn ? "Intraday Analysis" : "今日内分析",
|
||||
historyFuture: isEn
|
||||
? "Future-date + Decision Card Analysis"
|
||||
: "未来日期分析 + 城市决策卡",
|
||||
smartPush: isEn
|
||||
? "Cross-platform Smart Weather Push"
|
||||
: "全平台智能气象查询",
|
||||
deepMode: isEn
|
||||
? "Deep mode (incl. high-temp window)"
|
||||
: "深度版(含高温时段)",
|
||||
compactVisible: isEn ? "Compact visible" : "简版可见",
|
||||
enabled: isEn ? "Enabled" : "已开启",
|
||||
locked: isEn ? "Locked" : "锁定",
|
||||
boundEmail: isEn ? "Bound Email" : "绑定邮箱",
|
||||
loginMethod: isEn ? "Sign-in Method" : "登录方式",
|
||||
renewalDate: isEn ? "Renewal Date" : "续费日期",
|
||||
accessUntil: isEn ? "Access Until" : "可用至",
|
||||
authResult: isEn ? "Auth Result" : "鉴权结果",
|
||||
passed: isEn ? "Passed" : "通过",
|
||||
restricted: isEn ? "Restricted" : "受限",
|
||||
telegramBind: isEn ? "Telegram Bot Binding" : "Telegram Bot 绑定",
|
||||
telegramHint: isEn
|
||||
? "Use one-click Telegram binding first to sync notifications and access. After binding, refresh this page and submit your Telegram group join request."
|
||||
: "优先使用「一键绑定 Telegram Bot」同步通知与权限。绑定完成后刷新本页,再提交 Telegram 群组入群申请。",
|
||||
telegramFallbackHint: isEn
|
||||
? "Fallback copy method: only use this if one-click binding does not open Telegram correctly. Copy the command below and send it to @WeatherQuant_bot. After binding, refresh this page to show the group entry."
|
||||
: "兜底复制方式:仅在一键绑定无法正常打开 Telegram 时使用。请复制下方命令并发送给 @WeatherQuant_bot。绑定完成后刷新本页,即可显示入群入口。",
|
||||
paymentManualSupport: isEn
|
||||
? "If payment succeeds but Pro is still not activated, email yhrsc30@gmail.com. This project is currently maintained by one developer, so manual recovery may be needed in edge cases."
|
||||
: "如果付款成功后 Pro 仍未开通,请发邮件到 yhrsc30@gmail.com。当前项目由我一人维护,极少数边缘情况可能需要人工补开。给你带来的不便,敬请谅解!",
|
||||
telegramBotLink: isEn
|
||||
? "Open Bot (@WeatherQuant_bot)"
|
||||
: "打开机器人 (@WeatherQuant_bot)",
|
||||
telegramBotBindLink: isEn ? "One-click Telegram Binding" : "一键绑定 Telegram Bot",
|
||||
telegramGroupLink: isEn ? "Join Telegram Group" : "加入 Telegram 群组",
|
||||
telegramTopicsGroupLink: isEn
|
||||
? "Real-time Weather Updates"
|
||||
: "城市实测温度群",
|
||||
copyCommand: isEn ? "Copy fallback command" : "复制兜底命令",
|
||||
paymentMgmt: isEn ? "Payment Management" : "支付管理",
|
||||
paymentToken: isEn ? "Payment Token" : "支付币种",
|
||||
paymentAccount: isEn ? "Subscription Account" : "订阅归属账号",
|
||||
paymentWallet: isEn ? "Paying Wallet" : "付款钱包",
|
||||
paymentReceiver: isEn ? "Receiver Contract" : "当前收款合约",
|
||||
paymentNetwork: isEn ? "Payment Network" : "支付网络",
|
||||
paymentHost: isEn ? "Payment Host" : "支付域名",
|
||||
primary: "Primary",
|
||||
polygonChain: isEn ? "Polygon Network" : "Polygon 网络",
|
||||
noWallet: isEn ? "No payment wallet bound yet." : "未绑定任何付款钱包",
|
||||
bindExt: isEn
|
||||
? "Bind Browser Wallet (EVM Extension)"
|
||||
: "绑定浏览器钱包(EVM扩展)",
|
||||
bindQr: isEn
|
||||
? "Bind via QR (WalletConnect)"
|
||||
: "扫码绑定(WalletConnect)",
|
||||
walletConnectMissing: isEn
|
||||
? "WalletConnect disabled: please configure"
|
||||
: "未启用 WalletConnect:请配置",
|
||||
walletExtensionDetected: isEn
|
||||
? "Detected browser wallets"
|
||||
: "检测到的浏览器钱包",
|
||||
walletExtensionChoose: isEn
|
||||
? "Choose extension wallet"
|
||||
: "选择浏览器钱包",
|
||||
walletRecoveryBusy: isEn
|
||||
? "Recovering Pro entitlement after on-chain payment..."
|
||||
: "正在根据链上支付恢复 Pro 权限...",
|
||||
walletRecoveryDone: isEn
|
||||
? "Pro entitlement recovered."
|
||||
: "Pro 权限已恢复。",
|
||||
walletRecoveryFailed: isEn
|
||||
? "A recent on-chain payment is still syncing to your subscription. Please refresh in a minute or contact support."
|
||||
: "检测到最近的链上支付流程,但订阅状态仍在同步中。请稍后刷新,或联系管理员处理。",
|
||||
unbind: isEn ? "Unbind" : "解绑",
|
||||
unbindConfirm: isEn
|
||||
? "Unbind wallet {address}? You can bind it again later."
|
||||
: "确认解绑钱包 {address}?后续可重新绑定。",
|
||||
unbindDone: isEn ? "Wallet unbound." : "钱包已解绑。",
|
||||
unbindDonePrimary: isEn
|
||||
? "Wallet unbound. New primary: {address}"
|
||||
: "钱包已解绑,新的主钱包:{address}",
|
||||
unbindFailed: isEn ? "Failed to unbind wallet" : "解绑钱包失败",
|
||||
authExpired: isEn
|
||||
? "Session expired. Please sign out and sign in again."
|
||||
: "登录会话已失效,请退出后重新登录。",
|
||||
payNow: isEn ? "Subscribe & Activate" : "立即订阅并激活服务",
|
||||
connectAndPay: isEn ? "Connect Wallet & Pay" : "连接钱包并支付",
|
||||
loginBeforeBind: isEn
|
||||
? "Please sign in before binding wallet."
|
||||
: "请先登录后再绑定钱包。",
|
||||
loginBeforePay: isEn
|
||||
? "Please sign in before payment."
|
||||
: "请先登录后再支付。",
|
||||
bindFirstBeforePay: isEn
|
||||
? "Please bind a wallet first."
|
||||
: "请先绑定钱包。",
|
||||
payNotReady: isEn
|
||||
? "Payment service is not fully configured."
|
||||
: "支付服务未配置完成。",
|
||||
paymentHostBlocked: isEn
|
||||
? "Payments are disabled on this host. Please return to the production site: {host}"
|
||||
: "当前域名不允许发起支付,请回到主站后重试:{host}",
|
||||
paymentGuardHint: isEn
|
||||
? "Payment will be credited to the current account and bound wallet shown below."
|
||||
: "支付将记入下方显示的当前账号和绑定钱包,请先核对。",
|
||||
openBindFlow: isEn
|
||||
? "Please bind a wallet first. Opening bind flow..."
|
||||
: "请先完成钱包绑定,正在拉起绑定流程...",
|
||||
walletBoundCreatingOrder: isEn
|
||||
? "Wallet bound. Creating order and sending payment..."
|
||||
: "钱包已绑定,正在创建订单并发起支付...",
|
||||
proMember: "PRO MEMBER",
|
||||
freeTier: "FREE TIER",
|
||||
proPendingSync: isEn ? "Activated (pending sync)" : "已开通(待同步)",
|
||||
noProSubscription: isEn ? "No Pro subscription" : "暂无 Pro 订阅",
|
||||
proEndsSoonTitle: isEn ? "Pro renewal due soon" : "Pro 即将到期",
|
||||
proEndsSoonBody: isEn
|
||||
? "Your Pro membership will expire soon. Renew now to avoid interruption."
|
||||
: "你的 Pro 会员即将到期。现在续费可避免权限中断。",
|
||||
proExpiredTitle: isEn ? "Pro expired" : "Pro 已到期",
|
||||
proExpiredBody: isEn
|
||||
? "Your Pro membership has expired. Renew now to restore premium access."
|
||||
: "你的 Pro 会员已到期。立即续费可恢复高级权限。",
|
||||
renewNow: isEn ? "Renew Now" : "立即续费",
|
||||
daysLeft: isEn ? "{days} days left" : "剩余 {days} 天",
|
||||
queuedExtensionSummary: isEn
|
||||
? "Current plan until {current}. Queued extension: +{days} days. Total access until {total}."
|
||||
: "当前订阅至 {current},已排队延长 +{days} 天,总可用至 {total}。",
|
||||
paymentMethodLabel: isEn ? "Payment Method" : "请选择支付方式",
|
||||
paymentMethodWallet: isEn ? "Wallet Quick Pay" : "钱包快捷支付",
|
||||
paymentMethodManual: isEn ? "Manual On-chain Transfer" : "手动链上转账",
|
||||
paymentWalletDesc: isEn
|
||||
? "Option 1: Bind your EVM wallet (e.g. MetaMask extension or WalletConnect QR scan) to sign and pay via smart contract. Credits are credited instantly."
|
||||
: "方式一:绑定您的 EVM 钱包(如 MetaMask 扩展或 WalletConnect 扫码),通过智能合约自动签名付款,额度即时到账。",
|
||||
paymentGasWarning: isEn
|
||||
? "Your wallet needs a small amount of POL for gas fees; USDC alone may not complete authorization or payment. Please confirm your wallet is on Polygon network and keep some POL before paying."
|
||||
: "钱包里需要少量 POL 作为 gas 手续费;只有 USDC 可能无法完成授权或支付。请确认当前钱包在 Polygon 网络,并预留一点 POL 后再支付。",
|
||||
paymentManualDesc: isEn
|
||||
? "Option 2: Transfer directly to the platform's receiver contract without binding a wallet. After the transfer, submit the transaction hash (Tx Hash) and the system will verify and activate Pro automatically."
|
||||
: "方式二:无需将钱包绑定到账号,直接向平台收款合约转账。转账完成后提交交易哈希(Tx Hash)系统会自动验签并开通 Pro。",
|
||||
paymentManualTitle: isEn
|
||||
? "Manual Transfer (No Wallet Binding)"
|
||||
: "手动转账(无需绑定钱包)",
|
||||
paymentManualHint: isEn
|
||||
? "Create an order first, transfer to the specified receiver address, then submit the tx hash here. Do not mix with the wallet payment channel."
|
||||
: "先创建订单,向指定收款地址转账,完成后在此处提交 tx hash。请勿与钱包付款通道重复混用。",
|
||||
paymentManualCreate: isEn ? "Create Transfer Order" : "创建转账订单",
|
||||
paymentAmount: isEn ? "Amount" : "金额",
|
||||
paymentReceiverLabel: isEn ? "Receiver" : "收款地址",
|
||||
paymentTxHash: "Tx Hash",
|
||||
paymentCopyAddress: isEn ? "Copy" : "复制",
|
||||
paymentManualSubmit: isEn
|
||||
? "Submit Tx Hash & Confirm"
|
||||
: "提交 tx hash 并自动确认",
|
||||
chainReadError: isEn ? "Reading wallet network" : "读取钱包网络",
|
||||
chainSwitchError: isEn ? "Switch wallet network" : "切换钱包网络",
|
||||
chainAddPolygon: isEn ? "Add Polygon network" : "添加 Polygon 网络",
|
||||
chainSwitchPrompt: isEn
|
||||
? "Please manually switch to Polygon network in your wallet and try again."
|
||||
: "请在钱包中手动切换到 Polygon 网络后再试。",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const WALLETCONNECT_PROJECT_ID = String(
|
||||
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "",
|
||||
).trim();
|
||||
export const WALLETCONNECT_POLYGON_RPC_URL = String(
|
||||
process.env.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ||
|
||||
"https://polygon-bor-rpc.publicnode.com",
|
||||
).trim();
|
||||
export const TELEGRAM_GROUP_URL = "https://t.me/+Se93RpNQ58FhYmZh";
|
||||
export const TELEGRAM_BOT_URL = String(
|
||||
process.env.NEXT_PUBLIC_TELEGRAM_BOT_URL || "https://t.me/WeatherQuant_bot",
|
||||
).trim();
|
||||
export const TELEGRAM_TOPICS_GROUP_URL = TELEGRAM_GROUP_URL;
|
||||
export const SUBSCRIPTION_HELP_HREF = "/subscription-help";
|
||||
export const PAYMENT_RECOVERY_STORAGE_KEY = "polyweather:lastPaymentRecovery";
|
||||
export const PAYMENT_RECOVERY_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
export const WALLET_REQUEST_TIMEOUT_MS = 60_000;
|
||||
export const WALLET_TRANSACTION_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { PAYMENT_RECOVERY_STORAGE_KEY } from "./constants";
|
||||
|
||||
export function chainIdToDisplayName(chainId: number | undefined | null): string {
|
||||
if (chainId === 137) return "Polygon";
|
||||
if (chainId === 1) return "Ethereum Mainnet";
|
||||
if (chainId) return `Chain ID ${chainId}`;
|
||||
return "Polygon";
|
||||
}
|
||||
|
||||
export function formatTime(value: string | undefined | null, locale: string) {
|
||||
if (!value) return "--";
|
||||
try {
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return "--";
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(dt);
|
||||
} catch {
|
||||
return "--";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubscriptionExpiry(value: string | undefined | null) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return null;
|
||||
const dt = new Date(raw);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
const diffMs = dt.getTime() - Date.now();
|
||||
return {
|
||||
raw,
|
||||
date: dt,
|
||||
expired: diffMs <= 0,
|
||||
daysLeft: Math.ceil(diffMs / 86_400_000),
|
||||
};
|
||||
}
|
||||
|
||||
export function shortAddress(address: string) {
|
||||
const text = String(address || "");
|
||||
if (!text.startsWith("0x") || text.length < 12) return text || "--";
|
||||
return `${text.slice(0, 8)}...${text.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function clearStoredPaymentRecovery() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.sessionStorage.removeItem(PAYMENT_RECOVERY_STORAGE_KEY);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { EvmProvider } from "./types";
|
||||
import { WALLET_REQUEST_TIMEOUT_MS } from "./constants";
|
||||
import { isWalletConnectResetError } from "./wallet";
|
||||
|
||||
export function toPaddedHex(value: bigint) {
|
||||
return value.toString(16).padStart(64, "0");
|
||||
}
|
||||
|
||||
export function toPaddedAddress(address: string) {
|
||||
return String(address || "")
|
||||
.toLowerCase()
|
||||
.replace(/^0x/, "")
|
||||
.padStart(64, "0");
|
||||
}
|
||||
|
||||
export function buildAllowanceCalldata(owner: string, spender: string) {
|
||||
return `0xdd62ed3e${toPaddedAddress(owner)}${toPaddedAddress(spender)}`;
|
||||
}
|
||||
|
||||
export function buildApproveCalldata(spender: string, amount: bigint) {
|
||||
return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`;
|
||||
}
|
||||
|
||||
export function buildBalanceOfCalldata(owner: string) {
|
||||
return `0x70a08231${toPaddedAddress(owner)}`;
|
||||
}
|
||||
|
||||
export async function requestWalletWithTimeout<T>(
|
||||
provider: EvmProvider,
|
||||
args: { method: string; params?: unknown[] },
|
||||
actionLabel = "钱包操作",
|
||||
timeoutMs = WALLET_REQUEST_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return (await Promise.race([
|
||||
provider.request(args),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`${actionLabel}长时间无响应,请确认钱包弹窗是否被拦截;如使用 Binance Web3 Wallet,请回到钱包确认或重新连接后再试。`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
])) as T;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTokenUnits(amount: bigint, decimals: number) {
|
||||
const safeDecimals =
|
||||
Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : 6;
|
||||
const base = 10n ** BigInt(safeDecimals);
|
||||
const whole = amount / base;
|
||||
const fraction = amount % base;
|
||||
if (fraction === 0n) return whole.toString();
|
||||
const rawFraction = fraction.toString().padStart(safeDecimals, "0");
|
||||
const trimmed = rawFraction.replace(/0+$/, "");
|
||||
return `${whole.toString()}.${trimmed}`;
|
||||
}
|
||||
|
||||
export type NormalizedPaymentError = {
|
||||
message: string;
|
||||
pending: boolean;
|
||||
userRejected: boolean;
|
||||
};
|
||||
|
||||
export function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||
const source = error as any;
|
||||
const code = Number(
|
||||
source?.code ??
|
||||
source?.error?.code ??
|
||||
source?.data?.code ??
|
||||
source?.cause?.code ??
|
||||
NaN,
|
||||
);
|
||||
const messageCandidates = [
|
||||
source?.shortMessage,
|
||||
source?.message,
|
||||
source?.reason,
|
||||
source?.data?.message,
|
||||
source?.cause?.message,
|
||||
source?.error?.message,
|
||||
error instanceof Error ? error.message : "",
|
||||
typeof error === "string" ? error : "",
|
||||
];
|
||||
const rawMessage = messageCandidates
|
||||
.find(
|
||||
(item) =>
|
||||
typeof item === "string" &&
|
||||
item.trim() &&
|
||||
item.trim().toLowerCase() !== "[object object]",
|
||||
)
|
||||
?.trim();
|
||||
const lower = String(rawMessage || "").toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes("confirm pending") ||
|
||||
lower.includes("payment pending timeout")
|
||||
) {
|
||||
return {
|
||||
message: "链上交易已提交,正在确认中,请稍后刷新查看状态。",
|
||||
pending: true,
|
||||
userRejected: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isWalletConnectResetError(error)) {
|
||||
return {
|
||||
message:
|
||||
"WalletConnect 连接已重置,请重新扫码连接;若仍失败,请先在钱包里断开旧连接后再试。",
|
||||
pending: false,
|
||||
userRejected: false,
|
||||
};
|
||||
}
|
||||
|
||||
const userRejected =
|
||||
code === 4001 ||
|
||||
/user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消|签名请求已拒绝/.test(
|
||||
lower,
|
||||
);
|
||||
if (userRejected) {
|
||||
return {
|
||||
message: "你已取消钱包操作。",
|
||||
pending: false,
|
||||
userRejected: true,
|
||||
};
|
||||
}
|
||||
|
||||
const insufficientGas =
|
||||
(code === -32000 &&
|
||||
/insufficient funds/.test(lower) &&
|
||||
/(gas|fee|native|pol|matic)/.test(lower)) ||
|
||||
/not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/.test(
|
||||
lower,
|
||||
);
|
||||
if (insufficientGas) {
|
||||
return {
|
||||
message: "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。",
|
||||
pending: false,
|
||||
userRejected: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (rawMessage) {
|
||||
return {
|
||||
message: rawMessage,
|
||||
pending: false,
|
||||
userRejected: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
message: JSON.stringify(error),
|
||||
pending: false,
|
||||
userRejected: false,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
message: "发生未知错误,请稍后重试。",
|
||||
pending: false,
|
||||
userRejected: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
export type AuthMeResponse = {
|
||||
authenticated?: boolean;
|
||||
user_id?: string | null;
|
||||
email?: string | null;
|
||||
points?: number;
|
||||
weekly_points?: number;
|
||||
weekly_rank?: number | string | null;
|
||||
entitlement_mode?: string | null;
|
||||
auth_required?: boolean;
|
||||
subscription_required?: boolean;
|
||||
subscription_active?: boolean | null;
|
||||
subscription_plan_code?: string | null;
|
||||
subscription_starts_at?: string | null;
|
||||
subscription_expires_at?: string | null;
|
||||
subscription_total_expires_at?: string | null;
|
||||
subscription_queued_days?: number | null;
|
||||
subscription_queued_count?: number | null;
|
||||
telegram_pricing?: TelegramPricing | null;
|
||||
};
|
||||
|
||||
export type TelegramPricing = {
|
||||
configured?: boolean;
|
||||
telegram_id?: number | null;
|
||||
telegram_status?: string | null;
|
||||
is_group_member?: boolean;
|
||||
amount_usdc?: string;
|
||||
pricing_source?: string;
|
||||
};
|
||||
|
||||
export type PaymentPlan = {
|
||||
plan_code: string;
|
||||
plan_id: number;
|
||||
amount_usdc: string;
|
||||
duration_days: number;
|
||||
};
|
||||
|
||||
export type PaymentTokenOption = {
|
||||
code: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
receiver_contract?: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export type PointsRedemptionConfig = {
|
||||
enabled?: boolean;
|
||||
points_per_usdc?: number;
|
||||
max_discount_usdc?: number;
|
||||
};
|
||||
|
||||
export type PaymentConfig = {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
chain_id?: number;
|
||||
token_address?: string;
|
||||
token_decimals?: number;
|
||||
default_token_address?: string;
|
||||
tokens?: PaymentTokenOption[];
|
||||
receiver_contract?: string;
|
||||
confirmations?: number;
|
||||
points_redemption?: PointsRedemptionConfig;
|
||||
plans?: PaymentPlan[];
|
||||
};
|
||||
|
||||
export type BoundWallet = {
|
||||
chain_id: number;
|
||||
address: string;
|
||||
status: string;
|
||||
is_primary: boolean;
|
||||
verified_at?: string | null;
|
||||
};
|
||||
|
||||
export type CreatedIntent = {
|
||||
intent?: {
|
||||
intent_id: string;
|
||||
order_id_hex: string;
|
||||
plan_code: string;
|
||||
amount_usdc: string;
|
||||
allowed_wallet?: string | null;
|
||||
};
|
||||
tx_payload?: {
|
||||
chain_id: number;
|
||||
to: string;
|
||||
data: string;
|
||||
value: string;
|
||||
amount_units: string;
|
||||
token_address: string;
|
||||
token_symbol?: string;
|
||||
token_decimals?: number;
|
||||
};
|
||||
direct_payment?: {
|
||||
chain_id: number;
|
||||
chain?: string;
|
||||
token_symbol?: string;
|
||||
token_address: string;
|
||||
token_decimals?: number;
|
||||
receiver_address: string;
|
||||
amount_units: string;
|
||||
amount_usdc: string;
|
||||
intent_id: string;
|
||||
expires_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type IntentStatusResponse = {
|
||||
intent?: {
|
||||
intent_id?: string;
|
||||
status?: string;
|
||||
tx_hash?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ethereum?: EvmProvider;
|
||||
okxwallet?: {
|
||||
ethereum?: EvmProvider;
|
||||
};
|
||||
okexchain?: EvmProvider;
|
||||
rabby?: EvmProvider;
|
||||
bitkeep?: {
|
||||
ethereum?: EvmProvider;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type EvmProvider = {
|
||||
request: (args: { method: string; params?: any[] | object }) => Promise<any>;
|
||||
providers?: EvmProvider[];
|
||||
connect?: (args?: any) => Promise<void>;
|
||||
disconnect?: () => Promise<void>;
|
||||
session?: unknown;
|
||||
isMetaMask?: boolean;
|
||||
isRabby?: boolean;
|
||||
isOkxWallet?: boolean;
|
||||
isBitKeep?: boolean;
|
||||
};
|
||||
|
||||
export type ProviderMode = "auto" | "walletconnect";
|
||||
|
||||
export type ProviderSelection = {
|
||||
provider: EvmProvider;
|
||||
label: string;
|
||||
mode: ProviderMode;
|
||||
};
|
||||
|
||||
export type InjectedProviderOption = ProviderSelection & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type Eip6963ProviderInfo = {
|
||||
uuid: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rdns: string;
|
||||
};
|
||||
|
||||
export type Eip6963ProviderDetail = {
|
||||
info: Eip6963ProviderInfo;
|
||||
provider: EvmProvider;
|
||||
};
|
||||
|
||||
export type ConnectBindOptions = {
|
||||
openOverlayAfterBind?: boolean;
|
||||
};
|
||||
|
||||
export type PaymentRecoveryState = {
|
||||
intentId: string;
|
||||
txHash: string;
|
||||
userId: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { CreatedIntent } from "./types";
|
||||
|
||||
export type PaymentTxValidationState = {
|
||||
loading: boolean;
|
||||
checked: boolean;
|
||||
valid?: boolean;
|
||||
reason?: string;
|
||||
detail?: string;
|
||||
checks?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function usePaymentState() {
|
||||
const [paymentBusy, setPaymentBusy] = useState(false);
|
||||
const [paymentInfo, setPaymentInfo] = useState("");
|
||||
const [paymentError, setPaymentError] = useState("");
|
||||
const [lastIntentId, setLastIntentId] = useState("");
|
||||
const [lastTxHash, setLastTxHash] = useState("");
|
||||
const [telegramBindOpening, setTelegramBindOpening] = useState(false);
|
||||
const [manualPayment, setManualPayment] = useState<
|
||||
CreatedIntent["direct_payment"] | null
|
||||
>(null);
|
||||
const [manualTxHash, setManualTxHash] = useState("");
|
||||
const [txValidation, setTxValidation] = useState<PaymentTxValidationState>({
|
||||
loading: false,
|
||||
checked: false,
|
||||
});
|
||||
const [paymentMethodTab, setPaymentMethodTab] = useState<"wallet" | "manual">(
|
||||
"wallet",
|
||||
);
|
||||
const [lastPaymentStartedAt, setLastPaymentStartedAt] = useState(0);
|
||||
|
||||
const clearPaymentMessages = useCallback(() => {
|
||||
setPaymentError("");
|
||||
setPaymentInfo("");
|
||||
}, []);
|
||||
|
||||
const clearPaymentState = useCallback(() => {
|
||||
setLastIntentId("");
|
||||
setLastTxHash("");
|
||||
setManualPayment(null);
|
||||
setManualTxHash("");
|
||||
setLastPaymentStartedAt(0);
|
||||
setTxValidation({ loading: false, checked: false });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
paymentBusy,
|
||||
setPaymentBusy,
|
||||
paymentInfo,
|
||||
setPaymentInfo,
|
||||
paymentError,
|
||||
setPaymentError,
|
||||
lastIntentId,
|
||||
setLastIntentId,
|
||||
lastTxHash,
|
||||
setLastTxHash,
|
||||
telegramBindOpening,
|
||||
setTelegramBindOpening,
|
||||
manualPayment,
|
||||
setManualPayment,
|
||||
manualTxHash,
|
||||
setManualTxHash,
|
||||
txValidation,
|
||||
setTxValidation,
|
||||
paymentMethodTab,
|
||||
setPaymentMethodTab,
|
||||
lastPaymentStartedAt,
|
||||
setLastPaymentStartedAt,
|
||||
clearPaymentMessages,
|
||||
clearPaymentState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Eip6963ProviderDetail, EvmProvider, InjectedProviderOption } from "./types";
|
||||
import { WALLETCONNECT_POLYGON_RPC_URL, WALLETCONNECT_PROJECT_ID } from "./constants";
|
||||
|
||||
let walletConnectProviderCache: EvmProvider | null = null;
|
||||
let walletConnectProviderChainId: number | null = null;
|
||||
export const eip6963Providers = new Map<string, Eip6963ProviderDetail>();
|
||||
|
||||
export function isWalletConnectResetError(error: unknown): boolean {
|
||||
const source = error as any;
|
||||
const message = String(
|
||||
source?.shortMessage ||
|
||||
source?.message ||
|
||||
source?.reason ||
|
||||
source?.data?.message ||
|
||||
source?.cause?.message ||
|
||||
source?.error?.message ||
|
||||
(error instanceof Error ? error.message : "") ||
|
||||
(typeof error === "string" ? error : ""),
|
||||
).toLowerCase();
|
||||
return (
|
||||
message.includes("connection request reset") ||
|
||||
message.includes("pairing aborted") ||
|
||||
message.includes("pairing attempt") ||
|
||||
message.includes("unable to connect")
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetWalletConnectProvider(): Promise<void> {
|
||||
if (walletConnectProviderCache?.disconnect) {
|
||||
try {
|
||||
await walletConnectProviderCache.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
walletConnectProviderCache = null;
|
||||
walletConnectProviderChainId = null;
|
||||
}
|
||||
|
||||
export function getEvmProvider(): EvmProvider | null {
|
||||
return listInjectedProviders()[0]?.provider || null;
|
||||
}
|
||||
|
||||
export function getEip6963Providers(): Eip6963ProviderDetail[] {
|
||||
return Array.from(eip6963Providers.values());
|
||||
}
|
||||
|
||||
export function detectWalletLabel(
|
||||
provider: EvmProvider | null,
|
||||
detail?: Eip6963ProviderDetail,
|
||||
): string {
|
||||
if (!provider && !detail) return "EVM 钱包";
|
||||
const announcedName = String(detail?.info?.name || "").trim();
|
||||
const announcedRdns = String(detail?.info?.rdns || "").toLowerCase();
|
||||
|
||||
if (
|
||||
provider?.isOkxWallet ||
|
||||
announcedName.toLowerCase().includes("okx") ||
|
||||
announcedRdns.includes("okx")
|
||||
) {
|
||||
return "OKX Wallet";
|
||||
}
|
||||
if (
|
||||
provider?.isRabby ||
|
||||
announcedName.toLowerCase().includes("rabby") ||
|
||||
announcedRdns.includes("rabby")
|
||||
) {
|
||||
return "Rabby";
|
||||
}
|
||||
if (
|
||||
provider?.isBitKeep ||
|
||||
announcedName.toLowerCase().includes("bitget") ||
|
||||
announcedRdns.includes("bitkeep") ||
|
||||
announcedRdns.includes("bitget")
|
||||
) {
|
||||
return "Bitget Wallet";
|
||||
}
|
||||
if (
|
||||
(provider as any)?.isBinance ||
|
||||
(provider as any)?.bnbSign ||
|
||||
announcedName.toLowerCase().includes("binance") ||
|
||||
announcedRdns.includes("binance")
|
||||
) {
|
||||
return "Binance Web3 Wallet";
|
||||
}
|
||||
if (
|
||||
provider?.isMetaMask ||
|
||||
announcedName.toLowerCase().includes("metamask") ||
|
||||
announcedRdns.includes("metamask")
|
||||
) {
|
||||
return "MetaMask";
|
||||
}
|
||||
if (announcedName) return announcedName;
|
||||
return "EVM 钱包";
|
||||
}
|
||||
|
||||
export function collectInjectedProviders(): EvmProvider[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
const out: EvmProvider[] = [];
|
||||
const seen = new Set<EvmProvider>();
|
||||
|
||||
const push = (provider: unknown) => {
|
||||
if (!provider || typeof provider !== "object") return;
|
||||
const candidate = provider as EvmProvider;
|
||||
if (typeof candidate.request !== "function") return;
|
||||
if (seen.has(candidate)) return;
|
||||
seen.add(candidate);
|
||||
out.push(candidate);
|
||||
};
|
||||
|
||||
const root = window.ethereum;
|
||||
if (Array.isArray(root?.providers)) {
|
||||
root.providers.forEach(push);
|
||||
}
|
||||
push(root);
|
||||
push(window.okxwallet?.ethereum);
|
||||
push(window.okexchain);
|
||||
push(window.rabby);
|
||||
push(window.bitkeep?.ethereum);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getInjectedProviderStableId(
|
||||
provider: EvmProvider,
|
||||
index: number,
|
||||
detail?: Eip6963ProviderDetail,
|
||||
): string {
|
||||
const rdns = String(detail?.info?.rdns || "").toLowerCase();
|
||||
const announcedName = String(detail?.info?.name || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
if (rdns) return `rdns:${rdns}`;
|
||||
if (announcedName) return `name:${announcedName}`;
|
||||
if (provider.isOkxWallet || rdns.includes("okx")) return `okx:${index}`;
|
||||
if (provider.isMetaMask || rdns.includes("metamask"))
|
||||
return `metamask:${index}`;
|
||||
if (provider.isRabby || rdns.includes("rabby")) return `rabby:${index}`;
|
||||
if (
|
||||
provider.isBitKeep ||
|
||||
rdns.includes("bitkeep") ||
|
||||
rdns.includes("bitget")
|
||||
) {
|
||||
return `bitget:${index}`;
|
||||
}
|
||||
return `evm:${index}`;
|
||||
}
|
||||
|
||||
export function listInjectedProviders(): InjectedProviderOption[] {
|
||||
const detailByProvider = new Map<EvmProvider, Eip6963ProviderDetail>();
|
||||
getEip6963Providers().forEach((detail) => {
|
||||
if (detail?.provider && typeof detail.provider.request === "function") {
|
||||
detailByProvider.set(detail.provider, detail);
|
||||
}
|
||||
});
|
||||
const candidates = collectInjectedProviders();
|
||||
detailByProvider.forEach((_detail, provider) => {
|
||||
if (!candidates.includes(provider)) {
|
||||
candidates.push(provider);
|
||||
}
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
const seenLabels = new Set<string>();
|
||||
const out: InjectedProviderOption[] = [];
|
||||
candidates.forEach((provider, index) => {
|
||||
const detail = detailByProvider.get(provider);
|
||||
const label = detectWalletLabel(provider, detail);
|
||||
const key = getInjectedProviderStableId(provider, index, detail);
|
||||
if (seen.has(key)) return;
|
||||
const normalizedLabel = label.trim().toLowerCase();
|
||||
if (normalizedLabel && seenLabels.has(normalizedLabel)) return;
|
||||
seen.add(key);
|
||||
if (normalizedLabel) seenLabels.add(normalizedLabel);
|
||||
out.push({
|
||||
key,
|
||||
provider,
|
||||
label,
|
||||
mode: "auto",
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function getEvmWalletLabel(provider: EvmProvider | null): string {
|
||||
return detectWalletLabel(provider);
|
||||
}
|
||||
|
||||
export async function getWalletConnectProvider(
|
||||
chainId: number,
|
||||
rpcUrl: string,
|
||||
): Promise<EvmProvider> {
|
||||
if (!WALLETCONNECT_PROJECT_ID) {
|
||||
throw new Error(
|
||||
"WalletConnect 未配置:缺少 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID。",
|
||||
);
|
||||
}
|
||||
if (walletConnectProviderCache && walletConnectProviderChainId === chainId) {
|
||||
return walletConnectProviderCache;
|
||||
}
|
||||
const { EthereumProvider } = await import("@walletconnect/ethereum-provider");
|
||||
const rpcMap: Record<number, string> = {
|
||||
[chainId]: rpcUrl || WALLETCONNECT_POLYGON_RPC_URL,
|
||||
};
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://polyweather-pro.vercel.app";
|
||||
const provider = (await EthereumProvider.init({
|
||||
projectId: WALLETCONNECT_PROJECT_ID,
|
||||
chains: [chainId],
|
||||
optionalChains: [chainId],
|
||||
showQrModal: true,
|
||||
methods: [
|
||||
"eth_sendTransaction",
|
||||
"personal_sign",
|
||||
"eth_signTypedData",
|
||||
"eth_signTypedData_v4",
|
||||
"eth_sign",
|
||||
"eth_call",
|
||||
"eth_chainId",
|
||||
"eth_accounts",
|
||||
"eth_requestAccounts",
|
||||
],
|
||||
events: ["accountsChanged", "chainChanged", "disconnect"],
|
||||
rpcMap,
|
||||
metadata: {
|
||||
name: "PolyWeather",
|
||||
description: "PolyWeather Pro checkout",
|
||||
url: origin,
|
||||
icons: [`${origin}/favicon.ico`],
|
||||
},
|
||||
})) as unknown as EvmProvider;
|
||||
walletConnectProviderCache = provider;
|
||||
walletConnectProviderChainId = chainId;
|
||||
return provider;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user