拆分 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 serviceWorkerPath = path.join(projectRoot, "public", "sw.js");
|
||||||
|
|
||||||
const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8");
|
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 serviceWorkerSource = fs.readFileSync(serviceWorkerPath, "utf8");
|
||||||
const appAnalyticsSource = fs.readFileSync(
|
const appAnalyticsSource = fs.readFileSync(
|
||||||
path.join(projectRoot, "lib", "app-analytics.ts"),
|
path.join(projectRoot, "lib", "app-analytics.ts"),
|
||||||
@@ -64,18 +105,18 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!/label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/.test(
|
!/label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/.test(
|
||||||
accountCenterSource,
|
accountFeatureSource,
|
||||||
),
|
),
|
||||||
"Binance Web3 Wallet injected provider must remain available for browser-extension binding",
|
"Binance Web3 Wallet injected provider must remain available for browser-extension binding",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
accountCenterSource.includes("Binance 扩展已绑定") &&
|
accountFeatureSource.includes("Binance 扩展已绑定") &&
|
||||||
accountCenterSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
accountFeatureSource.includes("如支付卡住,请优先使用 WalletConnect 扫码支付"),
|
||||||
"Binance extension binding must show a WalletConnect fallback hint for payment stability",
|
"Binance extension binding must show a WalletConnect fallback hint for payment stability",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
accountCenterSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
accountFeatureSource.includes("钱包里需要少量 POL 作为 gas 手续费") &&
|
||||||
accountCenterSource.includes("只有 USDC 可能无法完成授权或支付"),
|
accountFeatureSource.includes("只有 USDC 可能无法完成授权或支付"),
|
||||||
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
|
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC",
|
||||||
);
|
);
|
||||||
assert(
|
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