nac-presale/client/src/components/WalletSelector.tsx

1036 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// NAC XIC Presale — Wallet Selector Component
// Detects installed EVM wallets and shows connect/install buttons for each
// v5: improved error handling, MetaMask permission reset guide, connection diagnostics
import { useState, useEffect, useCallback } from "react";
import type { EthProvider } from "@/hooks/useWallet";
type Lang = "zh" | "en";
interface WalletInfo {
id: string;
name: string;
icon: React.ReactNode;
installUrl: string;
mobileDeepLink?: string;
isInstalled: () => boolean;
connect: () => Promise<{ address: string; rawProvider: EthProvider } | null>;
network: "evm" | "tron";
}
interface WalletSelectorProps {
lang: Lang;
onAddressDetected: (address: string, network?: "evm" | "tron", rawProvider?: EthProvider) => void;
connectedAddress?: string;
compact?: boolean;
showTron?: boolean;
}
// ── Wallet Icons ──────────────────────────────────────────────────────────────
const MetaMaskIcon = () => (
<svg width="24" height="24" viewBox="0 0 35 33" fill="none">
<path d="M32.96 1L19.4 10.7l2.5-5.9L32.96 1z" fill="#E17726" stroke="#E17726" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2.04 1l13.46 9.8-2.38-5.99L2.04 1z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M28.22 23.53l-3.61 5.53 7.73 2.13 2.22-7.54-6.34-.12z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M.44 23.65l2.2 7.54 7.72-2.13-3.6-5.53-6.32.12z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9.97 14.46l-2.16 3.26 7.69.35-.26-8.27-5.27 4.66z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M25.03 14.46l-5.35-4.75-.17 8.36 7.68-.35-2.16-3.26z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M10.36 29.06l4.63-2.24-3.99-3.11-.64 5.35z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M20.01 26.82l4.63 2.24-.64-5.35-3.99 3.11z" fill="#E27625" stroke="#E27625" strokeWidth="0.25" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const TrustWalletIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#3375BB"/>
<path d="M12 4.5L6 7.5v5c0 3.31 2.57 6.41 6 7.5 3.43-1.09 6-4.19 6-7.5v-5L12 4.5z" fill="white" fillOpacity="0.9"/>
<path d="M10.5 12.5l1.5 1.5 3-3" stroke="#3375BB" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
const OKXIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#000"/>
<rect x="4" y="4" width="6" height="6" rx="1" fill="white"/>
<rect x="14" y="4" width="6" height="6" rx="1" fill="white"/>
<rect x="4" y="14" width="6" height="6" rx="1" fill="white"/>
<rect x="14" y="14" width="6" height="6" rx="1" fill="white"/>
<rect x="9" y="9" width="6" height="6" rx="1" fill="white"/>
</svg>
);
const CoinbaseIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#0052FF"/>
<circle cx="12" cy="12" r="7" fill="white"/>
<rect x="9" y="10.5" width="6" height="3" rx="1.5" fill="#0052FF"/>
</svg>
);
const TokenPocketIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#2980FE"/>
<path d="M7 8h5a3 3 0 0 1 0 6H7V8z" fill="white"/>
<rect x="7" y="15" width="2.5" height="3" rx="1" fill="white"/>
</svg>
);
const BitgetIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect width="24" height="24" rx="6" fill="#00F0FF"/>
<path d="M7 8h5a3 3 0 0 1 0 6H7V8z" fill="#000"/>
<path d="M12 14h2a3 3 0 0 1 0 6h-2v-6z" fill="#000"/>
</svg>
);
const TronLinkIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="12" fill="#FF0013"/>
<path d="M17.5 9.5L12 5L6.5 9.5V15.5L12 20L17.5 15.5V9.5Z" fill="white" fillOpacity="0.15" stroke="white" strokeWidth="0.5"/>
<path d="M12 7.5L8 10.5V14.5L12 17.5L16 14.5V10.5L12 7.5Z" fill="white" fillOpacity="0.9"/>
<path d="M12 10L10 11.5V13.5L12 15L14 13.5V11.5L12 10Z" fill="#FF0013"/>
</svg>
);
// ── Mobile detection ──────────────────────────────────────────────────────────
function isMobileBrowser(): boolean {
if (typeof window === "undefined") return false;
return /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
function isInWalletBrowser(): boolean {
if (typeof window === "undefined") return false;
const ua = navigator.userAgent.toLowerCase();
const w = window as unknown as Record<string, unknown>;
const eth = w.ethereum as { isMetaMask?: boolean; isTrust?: boolean; isTrustWallet?: boolean; isOKExWallet?: boolean; isOkxWallet?: boolean } | undefined;
const tronLink = w.tronLink as { ready?: boolean } | undefined;
return !!(
eth?.isMetaMask ||
eth?.isTrust ||
eth?.isTrustWallet ||
eth?.isOKExWallet ||
eth?.isOkxWallet ||
tronLink?.ready ||
ua.includes("metamask") ||
ua.includes("trust") ||
ua.includes("okex") ||
ua.includes("tokenpocket") ||
ua.includes("bitkeep") ||
ua.includes("tronlink")
);
}
// ── Provider detection helpers ────────────────────────────────────────────────
type TronLinkProvider = {
ready: boolean;
tronWeb?: {
defaultAddress?: { base58?: string };
trx?: unknown;
};
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
};
function getEth(): EthProvider | null {
if (typeof window === "undefined") return null;
return (window as unknown as { ethereum?: EthProvider }).ethereum ?? null;
}
function getOKX(): EthProvider | null {
if (typeof window === "undefined") return null;
return (window as unknown as { okxwallet?: EthProvider }).okxwallet ?? null;
}
function getBitget(): EthProvider | null {
if (typeof window === "undefined") return null;
const w = window as unknown as { bitkeep?: { ethereum?: EthProvider } };
return w.bitkeep?.ethereum ?? null;
}
function getTronLink(): TronLinkProvider | null {
if (typeof window === "undefined") return null;
const w = window as unknown as { tronLink?: TronLinkProvider; tronWeb?: TronLinkProvider["tronWeb"] };
if (w.tronLink?.ready) return w.tronLink;
if (w.tronWeb) {
return {
ready: true,
tronWeb: w.tronWeb,
request: async () => null,
};
}
return null;
}
function findProvider(predicate: (p: EthProvider) => boolean): EthProvider | null {
const eth = getEth();
if (!eth) return null;
if (eth.providers && Array.isArray(eth.providers)) {
return eth.providers.find(predicate) ?? null;
}
return predicate(eth) ? eth : null;
}
// ── Improved requestAccounts with better error classification ─────────────────
async function requestAccounts(provider: EthProvider): Promise<{ address: string; rawProvider: EthProvider } | null> {
try {
// First try eth_accounts (silent) to check if already connected
const existingAccounts = await provider.request({ method: "eth_accounts" }) as string[];
if (existingAccounts && existingAccounts.length > 0) {
return { address: existingAccounts[0], rawProvider: provider };
}
} catch {
// Ignore - proceed to eth_requestAccounts
}
try {
const accounts = await provider.request({ method: "eth_requestAccounts" }) as string[];
const address = accounts?.[0] ?? null;
if (!address) return null;
return { address, rawProvider: provider };
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
console.log("[WalletSelector] requestAccounts error:", error.code, error.message);
if (error?.code === 4001) throw new Error("user_rejected");
if (error?.code === -32002) throw new Error("wallet_pending");
// Some wallets throw with message instead of code
if (error?.message?.toLowerCase().includes("user rejected") ||
error?.message?.toLowerCase().includes("user denied") ||
error?.message?.toLowerCase().includes("cancelled")) {
throw new Error("user_rejected");
}
throw err;
}
}
async function requestTronAccounts(provider: TronLinkProvider): Promise<{ address: string; rawProvider: EthProvider } | null> {
try {
if (provider.tronWeb?.defaultAddress?.base58) {
return { address: provider.tronWeb.defaultAddress.base58, rawProvider: provider as unknown as EthProvider };
}
const result = await provider.request({ method: "tron_requestAccounts" }) as { code?: number; message?: string };
if (result?.code === 200) {
const w = window as unknown as { tronWeb?: { defaultAddress?: { base58?: string } } };
const address = w.tronWeb?.defaultAddress?.base58 ?? null;
if (!address) return null;
return { address, rawProvider: provider as unknown as EthProvider };
}
if (result?.code === 4001) throw new Error("user_rejected");
return null;
} catch (err: unknown) {
const error = err as { code?: number; message?: string };
if (error?.code === 4001 || error?.message === "user_rejected") throw new Error("user_rejected");
throw err;
}
}
// ── Wallet definitions ────────────────────────────────────────────────────────
function buildWallets(showTron: boolean): WalletInfo[] {
const currentUrl = typeof window !== "undefined" ? window.location.href : "https://pre-sale.newassetchain.io";
const urlWithoutProtocol = currentUrl.replace(/^https?:\/\//, "");
const evmWallets: WalletInfo[] = [
{
id: "metamask",
name: "MetaMask",
icon: <MetaMaskIcon />,
installUrl: "https://metamask.io/download/",
mobileDeepLink: `https://metamask.app.link/dapp/${urlWithoutProtocol}`,
isInstalled: () => !!findProvider(p => !!p.isMetaMask),
connect: async () => {
const p = findProvider(p => !!p.isMetaMask) ?? getEth();
return p ? requestAccounts(p) : null;
},
network: "evm",
},
{
id: "trust",
name: "Trust Wallet",
icon: <TrustWalletIcon />,
installUrl: "https://trustwallet.com/download",
mobileDeepLink: `https://link.trustwallet.com/open_url?coin_id=60&url=${encodeURIComponent(currentUrl)}`,
isInstalled: () => !!findProvider(p => !!(p.isTrust || p.isTrustWallet)),
connect: async () => {
const p = findProvider(p => !!(p.isTrust || p.isTrustWallet)) ?? getEth();
return p ? requestAccounts(p) : null;
},
network: "evm",
},
{
id: "okx",
name: "OKX Wallet",
icon: <OKXIcon />,
installUrl: "https://www.okx.com/web3",
mobileDeepLink: `okx://wallet/dapp/url?dappUrl=${encodeURIComponent(currentUrl)}`,
isInstalled: () => !!(getOKX() || findProvider(p => !!(p.isOKExWallet || p.isOkxWallet))),
connect: async () => {
const p = getOKX() ?? findProvider(p => !!(p.isOKExWallet || p.isOkxWallet));
return p ? requestAccounts(p) : null;
},
network: "evm",
},
{
id: "coinbase",
name: "Coinbase Wallet",
icon: <CoinbaseIcon />,
installUrl: "https://www.coinbase.com/wallet/downloads",
isInstalled: () => !!findProvider(p => !!p.isCoinbaseWallet),
connect: async () => {
const p = findProvider(p => !!p.isCoinbaseWallet) ?? getEth();
return p ? requestAccounts(p) : null;
},
network: "evm",
},
{
id: "tokenpocket",
name: "TokenPocket",
icon: <TokenPocketIcon />,
installUrl: "https://www.tokenpocket.pro/en/download/app",
isInstalled: () => !!findProvider(p => !!p.isTokenPocket),
connect: async () => {
const p = findProvider(p => !!p.isTokenPocket) ?? getEth();
return p ? requestAccounts(p) : null;
},
network: "evm",
},
{
id: "bitget",
name: "Bitget Wallet",
icon: <BitgetIcon />,
installUrl: "https://web3.bitget.com/en/wallet-download",
isInstalled: () => !!(getBitget() || findProvider(p => !!(p.isBitkeep || p.isBitgetWallet))),
connect: async () => {
const p = getBitget() ?? findProvider(p => !!(p.isBitkeep || p.isBitgetWallet));
return p ? requestAccounts(p) : null;
},
network: "evm",
},
];
const tronWallets: WalletInfo[] = [
{
id: "tronlink",
name: "TronLink",
icon: <TronLinkIcon />,
installUrl: "https://www.tronlink.org/",
mobileDeepLink: `tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({ url: currentUrl, action: "open", protocol: "tronlink", version: "1.0" }))}`,
isInstalled: () => !!getTronLink(),
connect: async () => {
const tron = getTronLink();
if (!tron) return null;
return requestTronAccounts(tron);
},
network: "tron",
},
];
return showTron ? [...tronWallets, ...evmWallets] : evmWallets;
}
// Validate Ethereum address format
function isValidEthAddress(addr: string): boolean {
return /^0x[0-9a-fA-F]{40}$/.test(addr);
}
// Validate TRON address format (T + 33 base58 chars = 34 chars total)
function isValidTronAddress(addr: string): boolean {
return /^T[1-9A-HJ-NP-Za-km-z]{33}$/.test(addr);
}
// ── Mobile DeepLink Panel ─────────────────────────────────────────────────────
function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean }) {
const currentUrl = typeof window !== "undefined" ? window.location.href : "https://pre-sale.newassetchain.io";
const urlWithoutProtocol = currentUrl.replace(/^https?:\/\//, "");
const [showMore, setShowMore] = useState(false);
const metamaskDeepLink = `https://metamask.app.link/dapp/${urlWithoutProtocol}`;
const otherWallets = [
...( showTron ? [{
id: "tronlink",
name: "TronLink",
icon: <TronLinkIcon />,
deepLink: `tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({ url: currentUrl, action: "open", protocol: "tronlink", version: "1.0" }))}`,
badge: "TRON",
badgeColor: "#FF0013",
}] : []),
{
id: "trust",
name: "Trust Wallet",
icon: <TrustWalletIcon />,
deepLink: `https://link.trustwallet.com/open_url?coin_id=60&url=${encodeURIComponent(currentUrl)}`,
badge: "EVM",
badgeColor: "#3375BB",
},
{
id: "okx",
name: "OKX Wallet",
icon: <OKXIcon />,
deepLink: `okx://wallet/dapp/url?dappUrl=${encodeURIComponent(currentUrl)}`,
badge: "EVM",
badgeColor: "#00F0FF",
},
{
id: "tokenpocket",
name: "TokenPocket",
icon: <TokenPocketIcon />,
deepLink: `tpdapp://open?params=${encodeURIComponent(JSON.stringify({ url: currentUrl, chain: "ETH", source: "NAC-Presale" }))}`,
badge: "EVM/TRON",
badgeColor: "#2980FE",
},
];
return (
<div className="space-y-3">
<a
href={metamaskDeepLink}
className="w-full flex items-center gap-3 px-4 py-4 rounded-2xl transition-all hover:opacity-90 active:scale-[0.98] block"
style={{
background: "linear-gradient(135deg, rgba(226,118,37,0.18) 0%, rgba(240,180,41,0.12) 100%)",
border: "1.5px solid rgba(226,118,37,0.5)",
boxShadow: "0 0 20px rgba(226,118,37,0.15)",
}}
>
<MetaMaskIcon />
<div className="flex-1">
<p className="text-sm font-bold text-white">MetaMask</p>
<p className="text-xs text-white/40">
{lang === "zh" ? "在 MetaMask 内置浏览器中打开" : "Open in MetaMask browser"}
</p>
</div>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(226,118,37,0.8)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<button
onClick={() => setShowMore(v => !v)}
className="w-full text-xs text-white/35 hover:text-white/55 transition-colors py-1 flex items-center justify-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ transform: showMore ? "rotate(180deg)" : "none", transition: "transform 0.2s" }}>
<path d="M6 9l6 6 6-6"/>
</svg>
{showMore
? (lang === "zh" ? "收起其他钱包" : "Hide other wallets")
: (lang === "zh" ? "其他钱包Trust / OKX / TokenPocket" : "Other wallets (Trust / OKX / TokenPocket)")}
</button>
{showMore && (
<div className="space-y-2">
{otherWallets.map(wallet => (
<a
key={wallet.id}
href={wallet.deepLink}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98] block"
style={{
background: "rgba(0,212,255,0.05)",
border: "1px solid rgba(0,212,255,0.18)",
}}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-sm font-semibold text-white">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0"
style={{ background: `${wallet.badgeColor}22`, color: wallet.badgeColor, border: `1px solid ${wallet.badgeColor}44` }}
>
{wallet.badge}
</span>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.5)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
))}
</div>
)}
</div>
);
}
// ── Error Help Panel ──────────────────────────────────────────────────────────
// Shows specific troubleshooting steps based on error type
function ErrorHelpPanel({ errorType, walletName, lang, onRetry }: {
errorType: "user_rejected" | "wallet_pending" | "not_initialized" | "unknown";
walletName: string;
lang: Lang;
onRetry: () => void;
}) {
const isZh = lang === "zh";
if (errorType === "user_rejected") {
return (
<div
className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(255,80,80,0.06)", border: "1px solid rgba(255,80,80,0.2)" }}
>
<div className="flex items-start gap-2">
<span className="text-red-400 text-base flex-shrink-0"></span>
<div>
<p className="text-sm font-semibold text-red-400">
{isZh ? "连接被拒绝" : "Connection Rejected"}
</p>
<p className="text-xs text-white/50 mt-1">
{isZh
? `${walletName} 拒绝了连接请求。可能原因:`
: `${walletName} rejected the connection. Possible reasons:`}
</p>
</div>
</div>
<div className="space-y-1.5 text-xs text-white/40 pl-4">
<p>
{isZh
? "1. 您在钱包弹窗中点击了「拒绝」"
: "1. You clicked \"Reject\" in the wallet popup"}
</p>
<p>
{isZh
? "2. 该网站之前被您在 MetaMask 中屏蔽(最常见原因)"
: "2. This site was previously blocked in your wallet (most common)"}
</p>
<p>
{isZh
? "3. 钱包弹窗未显示(被浏览器拦截)"
: "3. Wallet popup was blocked by browser"}
</p>
</div>
{walletName === "MetaMask" && (
<div
className="rounded-lg p-3 space-y-1.5"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.15)" }}
>
<p className="text-xs font-semibold text-amber-400">
{isZh ? "🔧 MetaMask 权限重置步骤:" : "🔧 MetaMask Permission Reset:"}
</p>
<div className="space-y-1 text-xs text-white/40">
<p>{isZh ? "① 点击 MetaMask 图标打开扩展" : "① Click MetaMask icon to open extension"}</p>
<p>{isZh ? "② 点击右上角菜单(三个点)" : "② Click top-right menu (three dots)"}</p>
<p>{isZh ? "③ 选择「已连接的网站」" : "③ Select \"Connected Sites\""}</p>
<p>{isZh ? "④ 找到本网站并删除" : "④ Find this site and remove it"}</p>
<p>{isZh ? "⑤ 回到此页面重新点击连接" : "⑤ Return here and try connecting again"}</p>
</div>
</div>
)}
<button
onClick={onRetry}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(0,212,255,0.12)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.25)" }}
>
{isZh ? "重试连接" : "Retry Connection"}
</button>
</div>
);
}
if (errorType === "wallet_pending") {
return (
<div
className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.2)" }}
>
<div className="flex items-start gap-2">
<span className="text-amber-400 text-base flex-shrink-0"></span>
<div>
<p className="text-sm font-semibold text-amber-400">
{isZh ? "钱包有待处理的请求" : "Wallet Has Pending Request"}
</p>
<p className="text-xs text-white/50 mt-1">
{isZh
? "请查看钱包弹窗并处理待处理的请求,然后重试"
: "Please check your wallet popup and handle the pending request, then retry"}
</p>
</div>
</div>
<button
onClick={onRetry}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(240,180,41,0.12)", color: "#f0b429", border: "1px solid rgba(240,180,41,0.25)" }}
>
{isZh ? "重试" : "Retry"}
</button>
</div>
);
}
if (errorType === "not_initialized") {
return (
<div
className="rounded-xl p-4 space-y-3"
style={{ background: "rgba(0,212,255,0.06)", border: "1px solid rgba(0,212,255,0.2)" }}
>
<div className="flex items-start gap-2">
<span className="text-cyan-400 text-base flex-shrink-0"></span>
<div>
<p className="text-sm font-semibold text-cyan-400">
{isZh ? "钱包未完成初始化" : "Wallet Not Initialized"}
</p>
<p className="text-xs text-white/50 mt-1">
{isZh
? "请先完成钱包设置(创建或导入钱包),然后刷新页面重试"
: "Please complete wallet setup (create or import wallet), then refresh the page"}
</p>
</div>
</div>
<button
onClick={() => window.location.reload()}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(0,212,255,0.12)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.25)" }}
>
{isZh ? "刷新页面" : "Refresh Page"}
</button>
</div>
);
}
// Unknown error
return (
<div
className="rounded-xl p-4 space-y-2"
style={{ background: "rgba(255,80,80,0.06)", border: "1px solid rgba(255,80,80,0.2)" }}
>
<p className="text-sm font-semibold text-red-400">
{isZh ? "连接失败" : "Connection Failed"}
</p>
<p className="text-xs text-white/40">
{isZh ? "请刷新页面后重试,或尝试其他钱包" : "Please refresh the page and try again, or try another wallet"}
</p>
<button
onClick={onRetry}
className="w-full py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90"
style={{ background: "rgba(0,212,255,0.12)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.25)" }}
>
{isZh ? "重试" : "Retry"}
</button>
</div>
);
}
// ── WalletSelector Component ──────────────────────────────────────────────────
export function WalletSelector({ lang, onAddressDetected, connectedAddress, compact = false, showTron = false }: WalletSelectorProps) {
const [wallets, setWallets] = useState<WalletInfo[]>([]);
const [connecting, setConnecting] = useState<string | null>(null);
const [errorType, setErrorType] = useState<"user_rejected" | "wallet_pending" | "not_initialized" | "unknown" | null>(null);
const [errorWalletName, setErrorWalletName] = useState<string>("");
const [detecting, setDetecting] = useState(true);
const [showManual, setShowManual] = useState(false);
const [manualAddress, setManualAddress] = useState("");
const [manualError, setManualError] = useState<string | null>(null);
const [isMobile] = useState(() => isMobileBrowser());
const [inWalletBrowser] = useState(() => isInWalletBrowser());
const [lastConnectedWallet, setLastConnectedWallet] = useState<WalletInfo | null>(null);
const detectWallets = useCallback(() => {
setDetecting(true);
setErrorType(null);
// Wait for wallet extensions to fully inject (up to 1500ms)
const timer = setTimeout(() => {
setWallets(buildWallets(showTron));
setDetecting(false);
}, 1500);
return () => clearTimeout(timer);
}, [showTron]);
useEffect(() => {
const cleanup = detectWallets();
return cleanup;
}, [detectWallets]);
const handleConnect = async (wallet: WalletInfo) => {
setConnecting(wallet.id);
setErrorType(null);
setLastConnectedWallet(wallet);
try {
const result = await wallet.connect();
if (result) {
onAddressDetected(result.address, wallet.network, result.rawProvider);
} else {
setErrorType("unknown");
setErrorWalletName(wallet.name);
}
} catch (err: unknown) {
const error = err as Error;
setErrorWalletName(wallet.name);
if (error.message === "user_rejected") {
setErrorType("user_rejected");
} else if (error.message === "wallet_pending") {
setErrorType("wallet_pending");
} else if (error.message?.includes("not initialized") || error.message?.includes("setup")) {
setErrorType("not_initialized");
} else {
setErrorType("unknown");
}
} finally {
setConnecting(null);
}
};
const handleRetry = useCallback(() => {
setErrorType(null);
if (lastConnectedWallet) {
handleConnect(lastConnectedWallet);
}
}, [lastConnectedWallet]); // eslint-disable-line react-hooks/exhaustive-deps
const handleManualSubmit = () => {
const addr = manualAddress.trim();
if (!addr) {
setManualError(lang === "zh" ? "请输入钱包地址" : "Please enter wallet address");
return;
}
if (isValidEthAddress(addr)) {
setManualError(null);
onAddressDetected(addr, "evm");
return;
}
if (isValidTronAddress(addr)) {
setManualError(null);
onAddressDetected(addr, "tron");
return;
}
setManualError(lang === "zh"
? "地址格式无效,请输入 EVM 地址0x开头或 TRON 地址T开头"
: "Invalid address. Enter an EVM address (0x...) or TRON address (T...)");
};
const installedWallets = wallets.filter(w => w.isInstalled());
const notInstalledWallets = wallets.filter(w => !w.isInstalled());
// If connected address is already set, show compact confirmation
if (connectedAddress) {
return (
<div
className="rounded-xl p-3 flex items-center gap-3"
style={{ background: "rgba(0,230,118,0.08)", border: "1px solid rgba(0,230,118,0.25)" }}
>
<div className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-green-400 font-semibold">
{lang === "zh" ? "钱包已连接" : "Wallet Connected"}
</p>
<p className="text-xs text-white/50 font-mono truncate">{connectedAddress}</p>
</div>
</div>
);
}
// ── Mobile browser (not in wallet app) — show DeepLink guide ──────────────
if (isMobile && !inWalletBrowser && !detecting) {
return (
<div className="space-y-3">
<MobileDeepLinkPanel lang={lang} showTron={showTron} />
{/* Manual address fallback */}
<div className="pt-1">
<button
onClick={() => { setShowManual(!showManual); setManualError(null); }}
className="w-full text-xs text-white/30 hover:text-white/50 transition-colors py-1 flex items-center justify-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{showManual
? (lang === "zh" ? "收起手动输入" : "Hide manual input")
: (lang === "zh" ? "手动输入钱包地址" : "Enter address manually")}
</button>
{showManual && (
<div className="mt-2 space-y-2">
<p className="text-xs text-white/40 text-center">
{lang === "zh"
? "输入 EVM 地址0x 开头)或 TRON 地址T 开头)"
: "Enter EVM address (0x...) or TRON address (T...)"}
</p>
<div className="flex gap-2">
<input
type="text"
value={manualAddress}
onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
placeholder={lang === "zh" ? "0x... 或 T..." : "0x... or T..."}
className="flex-1 px-3 py-2 rounded-lg text-xs font-mono text-white/80 outline-none focus:ring-1"
style={{
background: "rgba(255,255,255,0.06)",
border: manualError ? "1px solid rgba(255,80,80,0.5)" : "1px solid rgba(255,255,255,0.12)",
}}
onKeyDown={e => e.key === "Enter" && handleManualSubmit()}
/>
<button
onClick={handleManualSubmit}
className="px-3 py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90 active:scale-95 whitespace-nowrap"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.3)" }}
>
{lang === "zh" ? "确认" : "Confirm"}
</button>
</div>
{manualError && (
<p className="text-xs text-red-400">{manualError}</p>
)}
</div>
)}
</div>
</div>
);
}
// ── Loading state ─────────────────────────────────────────────────────────
if (detecting) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-white/60 uppercase tracking-wider">
{lang === "zh" ? "选择钱包连接" : "Select Wallet to Connect"}
</p>
</div>
<div className="flex items-center justify-center py-4 gap-2">
<svg className="animate-spin w-4 h-4 text-white/40" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span className="text-xs text-white/40">
{lang === "zh" ? "正在检测钱包..." : "Detecting wallets..."}
</span>
</div>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-white/60 uppercase tracking-wider">
{lang === "zh" ? "选择钱包连接" : "Select Wallet to Connect"}
</p>
{/* Refresh detection button */}
<button
onClick={detectWallets}
disabled={detecting}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-lg transition-all hover:opacity-80"
style={{ background: "rgba(0,212,255,0.1)", color: "rgba(0,212,255,0.7)", border: "1px solid rgba(0,212,255,0.2)" }}
title={lang === "zh" ? "重新检测钱包" : "Re-detect wallets"}
>
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
className={detecting ? "animate-spin" : ""}
>
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
{detecting
? (lang === "zh" ? "检测中..." : "Detecting...")
: (lang === "zh" ? "刷新" : "Refresh")}
</button>
</div>
{/* Connecting overlay */}
{connecting && (
<div
className="rounded-xl p-4 flex items-center gap-3"
style={{ background: "rgba(0,212,255,0.06)", border: "1px solid rgba(0,212,255,0.2)" }}
>
<svg className="animate-spin w-5 h-5 text-cyan-400 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<div>
<p className="text-sm font-semibold text-white/80">
{lang === "zh" ? "等待钱包授权..." : "Waiting for wallet authorization..."}
</p>
<p className="text-xs text-white/40 mt-0.5">
{lang === "zh" ? "请查看钱包弹窗并点击「连接」" : "Please check your wallet popup and click \"Connect\""}
</p>
</div>
</div>
)}
{/* Error panel */}
{!connecting && errorType && (
<ErrorHelpPanel
errorType={errorType}
walletName={errorWalletName}
lang={lang}
onRetry={handleRetry}
/>
)}
{/* Installed wallets */}
{!connecting && !errorType && installedWallets.length > 0 && (
<div className="space-y-2">
{installedWallets.map(wallet => (
<button
key={wallet.id}
onClick={() => handleConnect(wallet)}
disabled={!!connecting}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98]"
style={{
background: wallet.network === "tron" ? "rgba(255,0,19,0.08)" : "rgba(0,212,255,0.08)",
border: wallet.network === "tron" ? "1px solid rgba(255,0,19,0.3)" : "1px solid rgba(0,212,255,0.3)",
}}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-left text-sm font-semibold text-white">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={wallet.network === "tron"
? { background: "rgba(255,0,19,0.15)", color: "#FF4444" }
: { background: "rgba(0,212,255,0.15)", color: "#00d4ff" }}
>
{lang === "zh" ? "已安装" : "Installed"}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.7)" strokeWidth="2" className="flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
))}
</div>
)}
{/* Show installed wallets even when there's an error (for retry) */}
{!connecting && errorType && installedWallets.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-white/30 text-center">
{lang === "zh" ? "或选择其他钱包" : "Or try another wallet"}
</p>
{installedWallets.map(wallet => (
<button
key={wallet.id}
onClick={() => handleConnect(wallet)}
disabled={!!connecting}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all hover:opacity-90 active:scale-[0.98]"
style={{
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)",
}}
>
<span className="flex-shrink-0">{wallet.icon}</span>
<span className="flex-1 text-left text-sm font-semibold text-white/70">{wallet.name}</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.3)" strokeWidth="2" className="flex-shrink-0">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
))}
</div>
)}
{/* No wallets installed — desktop */}
{!connecting && installedWallets.length === 0 && (
<div
className="rounded-xl p-4 text-center"
style={{ background: "rgba(255,255,255,0.04)", border: "1px dashed rgba(255,255,255,0.15)" }}
>
<p className="text-sm text-white/50 mb-1">
{lang === "zh"
? (showTron ? "未检测到 EVM 或 TRON 钱包" : "未检测到 EVM 钱包")
: (showTron ? "No EVM or TRON wallet detected" : "No EVM wallet detected")}
</p>
<p className="text-xs text-white/30 mb-3">
{lang === "zh"
? "请安装以下任一钱包,完成设置后点击上方「刷新」按钮"
: "Install any wallet below, then click Refresh above after setup"}
</p>
{!showTron && (
<p className="text-xs text-amber-400/70">
{lang === "zh"
? "💡 已安装MetaMask请先完成钱包初始化创建或导入钱包再点击刷新"
: "💡 Have MetaMask? Complete wallet setup (create or import) first, then click Refresh"}
</p>
)}
</div>
)}
{/* Not installed wallets */}
{!connecting && notInstalledWallets.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-white/25 uppercase tracking-wider">
{lang === "zh" ? "未安装" : "Not installed"}
</p>
{notInstalledWallets.map(wallet => (
<a
key={wallet.id}
href={wallet.installUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all hover:opacity-80"
style={{
background: "rgba(255,255,255,0.02)",
border: "1px dashed rgba(255,255,255,0.1)",
}}
>
<span className="flex-shrink-0 opacity-50">{wallet.icon}</span>
<span className="flex-1 text-left text-sm text-white/40">{wallet.name}</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ background: "rgba(255,255,255,0.05)", color: "rgba(255,255,255,0.3)" }}
>
{lang === "zh" ? "点击安装" : "Install"}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.2)" strokeWidth="2" className="flex-shrink-0">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
))}
</div>
)}
{/* Manual address input */}
<div className="pt-1">
<button
onClick={() => { setShowManual(!showManual); setManualError(null); }}
className="w-full text-xs text-white/30 hover:text-white/50 transition-colors py-1 flex items-center justify-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
{showManual
? (lang === "zh" ? "收起手动输入" : "Hide manual input")
: (lang === "zh" ? "手动输入钱包地址" : "Enter address manually")}
</button>
{showManual && (
<div className="mt-2 space-y-2">
<p className="text-xs text-white/40 text-center">
{lang === "zh"
? "输入 EVM 地址0x 开头)或 TRON 地址T 开头)"
: "Enter EVM address (0x...) or TRON address (T...)"}
</p>
<div className="flex gap-2">
<input
type="text"
value={manualAddress}
onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
placeholder={lang === "zh" ? "0x... 或 T..." : "0x... or T..."}
className="flex-1 px-3 py-2 rounded-lg text-xs font-mono text-white/80 outline-none focus:ring-1"
style={{
background: "rgba(255,255,255,0.06)",
border: manualError ? "1px solid rgba(255,80,80,0.5)" : "1px solid rgba(255,255,255,0.12)",
}}
onKeyDown={e => e.key === "Enter" && handleManualSubmit()}
/>
<button
onClick={handleManualSubmit}
className="px-3 py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90 active:scale-95 whitespace-nowrap"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.3)" }}
>
{lang === "zh" ? "确认" : "Confirm"}
</button>
</div>
{manualError && (
<p className="text-xs text-red-400">{manualError}</p>
)}
</div>
)}
</div>
</div>
);
}