拆分 AccountCenter 超大组件:提取类型、常量、钱包、支付等独立模块

- AccountCenter.tsx 从 3565 行缩减约 1000 行
- 新增 8 个模块:types / constants / formatters / account-copy / wallet / payment-utils / usePaymentState / AccountInfoRow
- 同步更新 paymentShell 测试

Confidence: high
This commit is contained in:
2569718930@qq.com
2026-05-23 23:30:48 +08:00
parent b2dd758977
commit 89394e12ef
10 changed files with 1090 additions and 927 deletions
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(
+181
View File
@@ -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 网络后再试。",
};
}
+18
View File
@@ -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;
+49
View File
@@ -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,
};
}
}
+175
View File
@@ -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,
};
}
+237
View File
@@ -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;
}