Checkpoint: 修复手机端钱包连接弹出框问题:

1. Modal 改为手机端底部弹出(items-end),加 maxHeight 85vh + overflow-y-auto,防止内容超出屏幕
2. MobileDeepLinkPanel 精简:MetaMask 单独突出显示在最上方,其他钱包(Trust/OKX/TokenPocket)默认折叠
3. 手机端在钱包内置浏览器中(window.ethereum 已注入)时,直接调起连接,不弹选择列表
4. 手机端隐藏桌面专用的 MetaMask 扩展初始化提示
另外:停止了旧预售合约 0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c(调用 endPresale(),TX: 0x286e35...)
This commit is contained in:
Manus 2026-03-10 01:48:13 -04:00
parent dd24e6ba13
commit a7aa132b71
4 changed files with 270 additions and 238 deletions

View File

@ -3,6 +3,7 @@
// v4: added TronLink support (desktop window.tronLink + mobile DeepLink) // v4: added TronLink support (desktop window.tronLink + mobile DeepLink)
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { EthProvider } from "@/hooks/useWallet";
type Lang = "zh" | "en"; type Lang = "zh" | "en";
@ -13,13 +14,14 @@ interface WalletInfo {
installUrl: string; installUrl: string;
mobileDeepLink?: string; // DeepLink to open current page in wallet's in-app browser mobileDeepLink?: string; // DeepLink to open current page in wallet's in-app browser
isInstalled: () => boolean; isInstalled: () => boolean;
connect: () => Promise<string | null>; connect: () => Promise<{ address: string; rawProvider: EthProvider } | null>;
network: "evm" | "tron"; // wallet network type network: "evm" | "tron"; // wallet network type
} }
interface WalletSelectorProps { interface WalletSelectorProps {
lang: Lang; lang: Lang;
onAddressDetected: (address: string, network?: "evm" | "tron") => void; // rawProvider is passed so parent can call wallet.connectWithProvider() directly
onAddressDetected: (address: string, network?: "evm" | "tron", rawProvider?: EthProvider) => void;
connectedAddress?: string; connectedAddress?: string;
compact?: boolean; // compact mode for BSC/ETH panel compact?: boolean; // compact mode for BSC/ETH panel
showTron?: boolean; // whether to show TRON wallets (for TRC20 panel) showTron?: boolean; // whether to show TRON wallets (for TRC20 panel)
@ -124,20 +126,7 @@ function isInWalletBrowser(): boolean {
} }
// ── Provider detection helpers ──────────────────────────────────────────────── // ── Provider detection helpers ────────────────────────────────────────────────
// Note: EthProvider type is imported from useWallet.ts
type EthProvider = {
isMetaMask?: boolean;
isTrust?: boolean;
isTrustWallet?: boolean;
isOKExWallet?: boolean;
isOkxWallet?: boolean;
isCoinbaseWallet?: boolean;
isTokenPocket?: boolean;
isBitkeep?: boolean;
isBitgetWallet?: boolean;
providers?: EthProvider[];
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
};
type TronLinkProvider = { type TronLinkProvider = {
ready: boolean; ready: boolean;
@ -190,10 +179,12 @@ function findProvider(predicate: (p: EthProvider) => boolean): EthProvider | nul
return predicate(eth) ? eth : null; return predicate(eth) ? eth : null;
} }
async function requestAccounts(provider: EthProvider): Promise<string | null> { async function requestAccounts(provider: EthProvider): Promise<{ address: string; rawProvider: EthProvider } | null> {
try { try {
const accounts = await provider.request({ method: "eth_requestAccounts" }) as string[]; const accounts = await provider.request({ method: "eth_requestAccounts" }) as string[];
return accounts?.[0] ?? null; const address = accounts?.[0] ?? null;
if (!address) return null;
return { address, rawProvider: provider };
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { code?: number; message?: string }; const error = err as { code?: number; message?: string };
if (error?.code === 4001) throw new Error("user_rejected"); if (error?.code === 4001) throw new Error("user_rejected");
@ -202,18 +193,20 @@ async function requestAccounts(provider: EthProvider): Promise<string | null> {
} }
} }
async function requestTronAccounts(provider: TronLinkProvider): Promise<string | null> { async function requestTronAccounts(provider: TronLinkProvider): Promise<{ address: string; rawProvider: EthProvider } | null> {
try { try {
// TronLink v1: use tronWeb.defaultAddress // TronLink v1: use tronWeb.defaultAddress
if (provider.tronWeb?.defaultAddress?.base58) { if (provider.tronWeb?.defaultAddress?.base58) {
return provider.tronWeb.defaultAddress.base58; return { address: provider.tronWeb.defaultAddress.base58, rawProvider: provider as unknown as EthProvider };
} }
// TronLink v2+: use request method // TronLink v2+: use request method
const result = await provider.request({ method: "tron_requestAccounts" }) as { code?: number; message?: string }; const result = await provider.request({ method: "tron_requestAccounts" }) as { code?: number; message?: string };
if (result?.code === 200) { if (result?.code === 200) {
// After approval, tronWeb.defaultAddress should be populated // After approval, tronWeb.defaultAddress should be populated
const w = window as unknown as { tronWeb?: { defaultAddress?: { base58?: string } } }; const w = window as unknown as { tronWeb?: { defaultAddress?: { base58?: string } } };
return w.tronWeb?.defaultAddress?.base58 ?? null; 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"); if (result?.code === 4001) throw new Error("user_rejected");
return null; return null;
@ -348,35 +341,25 @@ function isValidAddress(addr: string): boolean {
function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean }) { function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean }) {
const currentUrl = typeof window !== "undefined" ? window.location.href : "https://pre-sale.newassetchain.io"; const currentUrl = typeof window !== "undefined" ? window.location.href : "https://pre-sale.newassetchain.io";
const urlWithoutProtocol = currentUrl.replace(/^https?:\/\//, ""); const urlWithoutProtocol = currentUrl.replace(/^https?:\/\//, "");
const [showMore, setShowMore] = useState(false);
const tronWallets = [ // MetaMask is always the primary wallet (most widely used)
{ const metamaskDeepLink = `https://metamask.app.link/dapp/${urlWithoutProtocol}`;
const otherWallets = [
...( showTron ? [{
id: "tronlink", id: "tronlink",
name: "TronLink", name: "TronLink",
icon: <TronLinkIcon />, icon: <TronLinkIcon />,
deepLink: `tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({ url: currentUrl, action: "open", protocol: "tronlink", version: "1.0" }))}`, deepLink: `tronlinkoutside://pull.activity?param=${encodeURIComponent(JSON.stringify({ url: currentUrl, action: "open", protocol: "tronlink", version: "1.0" }))}`,
installUrl: "https://www.tronlink.org/",
badge: "TRON", badge: "TRON",
badgeColor: "#FF0013", badgeColor: "#FF0013",
}, }] : []),
];
const evmWallets = [
{
id: "metamask",
name: "MetaMask",
icon: <MetaMaskIcon />,
deepLink: `https://metamask.app.link/dapp/${urlWithoutProtocol}`,
installUrl: "https://metamask.io/download/",
badge: "EVM",
badgeColor: "#E27625",
},
{ {
id: "trust", id: "trust",
name: "Trust Wallet", name: "Trust Wallet",
icon: <TrustWalletIcon />, icon: <TrustWalletIcon />,
deepLink: `https://link.trustwallet.com/open_url?coin_id=60&url=${encodeURIComponent(currentUrl)}`, deepLink: `https://link.trustwallet.com/open_url?coin_id=60&url=${encodeURIComponent(currentUrl)}`,
installUrl: "https://trustwallet.com/download",
badge: "EVM", badge: "EVM",
badgeColor: "#3375BB", badgeColor: "#3375BB",
}, },
@ -385,7 +368,6 @@ function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean
name: "OKX Wallet", name: "OKX Wallet",
icon: <OKXIcon />, icon: <OKXIcon />,
deepLink: `okx://wallet/dapp/url?dappUrl=${encodeURIComponent(currentUrl)}`, deepLink: `okx://wallet/dapp/url?dappUrl=${encodeURIComponent(currentUrl)}`,
installUrl: "https://www.okx.com/web3",
badge: "EVM", badge: "EVM",
badgeColor: "#00F0FF", badgeColor: "#00F0FF",
}, },
@ -394,90 +376,80 @@ function MobileDeepLinkPanel({ lang, showTron }: { lang: Lang; showTron: boolean
name: "TokenPocket", name: "TokenPocket",
icon: <TokenPocketIcon />, icon: <TokenPocketIcon />,
deepLink: `tpdapp://open?params=${encodeURIComponent(JSON.stringify({ url: currentUrl, chain: "ETH", source: "NAC-Presale" }))}`, deepLink: `tpdapp://open?params=${encodeURIComponent(JSON.stringify({ url: currentUrl, chain: "ETH", source: "NAC-Presale" }))}`,
installUrl: "https://www.tokenpocket.pro/en/download/app",
badge: "EVM/TRON", badge: "EVM/TRON",
badgeColor: "#2980FE", badgeColor: "#2980FE",
}, },
]; ];
const walletList = showTron ? [...tronWallets, ...evmWallets] : evmWallets;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Mobile guidance header */} {/* Primary: MetaMask — large prominent button */}
<div <a
className="rounded-xl p-4" href={metamaskDeepLink}
style={{ background: "rgba(240,180,41,0.08)", border: "1px solid rgba(240,180,41,0.25)" }} 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)",
}}
> >
<div className="flex items-start gap-3"> <MetaMaskIcon />
<span className="text-xl flex-shrink-0">📱</span> <div className="flex-1">
<div> <p className="text-sm font-bold text-white">MetaMask</p>
<p className="text-sm font-semibold text-amber-300 mb-1"> <p className="text-xs text-white/40">
{lang === "zh" ? "手机端连接钱包" : "Connect Wallet on Mobile"} {lang === "zh" ? "在 MetaMask 内置浏览器中打开" : "Open in MetaMask browser"}
</p> </p>
<p className="text-xs text-white/50 leading-relaxed">
{lang === "zh"
? "手机浏览器不支持钱包扩展。请选择以下任一钱包 App在其内置浏览器中打开本页面即可连接钱包。"
: "Mobile browsers don't support wallet extensions. Open this page in a wallet app's built-in browser to connect."}
</p>
</div>
</div> </div>
</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>
{/* Wallet DeepLink buttons */} {/* Other wallets — collapsed by default */}
<div className="space-y-2"> <button
<p className="text-xs text-white/40 text-center"> onClick={() => setShowMore(v => !v)}
{lang === "zh" ? "选择钱包 App 打开本页面" : "Choose a wallet app to open this page"} className="w-full text-xs text-white/35 hover:text-white/55 transition-colors py-1 flex items-center justify-center gap-1"
</p>
{walletList.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.06)",
border: "1px solid rgba(0,212,255,0.2)",
}}
>
<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>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0"
style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff" }}
>
{lang === "zh" ? "在 App 中打开" : "Open in App"}
</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(0,212,255,0.6)" 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>
{/* Step guide */}
<div
className="rounded-xl p-3 space-y-2"
style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.08)" }}
> >
<p className="text-xs font-semibold text-white/50 mb-2"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
{lang === "zh" ? "操作步骤" : "How it works"} style={{ transform: showMore ? "rotate(180deg)" : "none", transition: "transform 0.2s" }}>
</p> <path d="M6 9l6 6 6-6"/>
{[ </svg>
lang === "zh" ? "1. 点击上方任一钱包 App 按钮" : "1. Tap any wallet app button above", {showMore
lang === "zh" ? "2. 在钱包 App 的内置浏览器中打开本页面" : "2. Page opens in the wallet app's browser", ? (lang === "zh" ? "收起其他钱包" : "Hide other wallets")
lang === "zh" ? "3. 点击「连接钱包」即可自动连接" : "3. Tap 'Connect Wallet' to connect automatically", : (lang === "zh" ? "其他钱包Trust / OKX / TokenPocket" : "Other wallets (Trust / OKX / TokenPocket)")}
].map((step, i) => ( </button>
<p key={i} className="text-xs text-white/35 leading-relaxed">{step}</p>
))} {showMore && (
</div> <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> </div>
); );
} }
@ -515,9 +487,10 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
setConnecting(wallet.id); setConnecting(wallet.id);
setError(null); setError(null);
try { try {
const address = await wallet.connect(); const result = await wallet.connect();
if (address) { if (result) {
onAddressDetected(address, wallet.network); // Pass rawProvider so parent can call wallet.connectWithProvider() directly
onAddressDetected(result.address, wallet.network, result.rawProvider);
} else { } else {
setError(lang === "zh" ? "未获取到地址,请重试" : "No address returned, please try again"); setError(lang === "zh" ? "未获取到地址,请重试" : "No address returned, please try again");
} }
@ -582,66 +555,65 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
} }
// ── Mobile browser (not in wallet app) — show DeepLink guide ────────────── // ── Mobile browser (not in wallet app) — show DeepLink guide ──────────────
// On mobile browsers, ALWAYS show DeepLink guide regardless of whether wallets are "detected"
// because window.ethereum/okxwallet is NOT available in mobile browsers (only in wallet app's built-in browser)
if (isMobile && !inWalletBrowser && !detecting) { if (isMobile && !inWalletBrowser && !detecting) {
const hasInstalledWallet = installedWallets.length > 0; return (
<div className="space-y-3">
<MobileDeepLinkPanel lang={lang} showTron={showTron} />
if (!hasInstalledWallet) { {/* Manual address fallback */}
return ( <div className="pt-1">
<div className="space-y-3"> <button
<MobileDeepLinkPanel lang={lang} showTron={showTron} /> 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>
{/* Manual address fallback */} {showManual && (
<div className="pt-1"> <div className="mt-2 space-y-2">
<button <p className="text-xs text-white/40 text-center">
onClick={() => { setShowManual(!showManual); setManualError(null); }} {lang === "zh"
className="w-full text-xs text-white/30 hover:text-white/50 transition-colors py-1 flex items-center justify-center gap-1" ? "输入 EVM 地址0x 开头)或 TRON 地址T 开头)"
> : "Enter EVM address (0x...) or TRON address (T...)"}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> </p>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/> <div className="flex gap-2">
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/> <input
</svg> type="text"
{showManual value={manualAddress}
? (lang === "zh" ? "收起手动输入" : "Hide manual input") onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
: (lang === "zh" ? "手动输入钱包地址" : "Enter address manually")} placeholder={lang === "zh" ? "0x... 或 T..." : "0x... or T..."}
</button> className="flex-1 px-3 py-2 rounded-lg text-xs font-mono text-white/80 outline-none focus:ring-1"
style={{
{showManual && ( background: "rgba(255,255,255,0.06)",
<div className="mt-2 space-y-2"> border: manualError ? "1px solid rgba(255,80,80,0.5)" : "1px solid rgba(255,255,255,0.12)",
<p className="text-xs text-white/40 text-center"> }}
{lang === "zh" onKeyDown={e => e.key === "Enter" && handleManualSubmit()}
? "输入 EVM 地址0x 开头)或 TRON 地址T 开头)" />
: "Enter EVM address (0x...) or TRON address (T...)"} <button
</p> onClick={handleManualSubmit}
<div className="flex gap-2"> className="px-3 py-2 rounded-lg text-xs font-semibold transition-all hover:opacity-90 active:scale-95 whitespace-nowrap"
<input style={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff", border: "1px solid rgba(0,212,255,0.3)" }}
type="text" >
value={manualAddress} {lang === "zh" ? "确认" : "Confirm"}
onChange={e => { setManualAddress(e.target.value); setManualError(null); }} </button>
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>
)} {manualError && (
</div> <p className="text-xs text-red-400">{manualError}</p>
)}
</div>
)}
</div> </div>
); </div>
} );
} }
// ── Loading state ───────────────────────────────────────────────────────── // ── Loading state ─────────────────────────────────────────────────────────
@ -765,8 +737,8 @@ export function WalletSelector({ lang, onAddressDetected, connectedAddress, comp
</div> </div>
)} )}
{/* Not-installed wallets — show install links */} {/* Not-installed wallets — only show when NO wallet is installed (UI fix: don't mix installed+uninstalled) */}
{!compact && notInstalledWallets.length > 0 && ( {!compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-white/30 mt-2"> <p className="text-xs text-white/30 mt-2">
{lang === "zh" ? "未安装(点击安装)" : "Not installed (click to install)"} {lang === "zh" ? "未安装(点击安装)" : "Not installed (click to install)"}

View File

@ -1,6 +1,6 @@
// NAC XIC Presale — Wallet Connection Hook // NAC XIC Presale — Wallet Connection Hook
// Supports MetaMask, TokenPocket, OKX, Bitget, Trust Wallet, imToken, SafePal, and all EVM wallets // Supports MetaMask, TokenPocket, OKX, Bitget, Trust Wallet, imToken, SafePal, and all EVM wallets
// v4: improved Chinese wallet support (TokenPocket, OKX, Bitget first priority) // v5: added connectWithProvider() to fix state sync when WalletSelector connects via external provider
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { BrowserProvider, JsonRpcSigner, Eip1193Provider } from "ethers"; import { BrowserProvider, JsonRpcSigner, Eip1193Provider } from "ethers";
@ -30,7 +30,7 @@ const INITIAL_STATE: WalletState = {
error: null, error: null,
}; };
type EthProvider = Eip1193Provider & { export type EthProvider = Eip1193Provider & {
isMetaMask?: boolean; isMetaMask?: boolean;
isTrust?: boolean; isTrust?: boolean;
isTrustWallet?: boolean; isTrustWallet?: boolean;
@ -45,12 +45,12 @@ type EthProvider = Eip1193Provider & {
// Detect the best available EVM provider across all major wallets // Detect the best available EVM provider across all major wallets
// Priority: TokenPocket > OKX > Bitget > Trust Wallet > MetaMask > others // Priority: TokenPocket > OKX > Bitget > Trust Wallet > MetaMask > others
export function detectProvider(): Eip1193Provider | null { export function detectProvider(): EthProvider | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
const w = window as unknown as Record<string, unknown>; const w = window as unknown as Record<string, unknown>;
// 1. TokenPocket — injects window.ethereum with isTokenPocket flag // 1. Check window.ethereum (most wallets inject here)
const eth = w.ethereum as EthProvider | undefined; const eth = w.ethereum as EthProvider | undefined;
if (eth) { if (eth) {
// Check providers array first (multiple extensions installed) // Check providers array first (multiple extensions installed)
@ -74,21 +74,21 @@ export function detectProvider(): Eip1193Provider | null {
} }
// 2. OKX Wallet — sometimes injects window.okxwallet separately // 2. OKX Wallet — sometimes injects window.okxwallet separately
if (w.okxwallet) return w.okxwallet as Eip1193Provider; if (w.okxwallet) return w.okxwallet as EthProvider;
// 3. Bitget Wallet — sometimes injects window.bitkeep.ethereum // 3. Bitget Wallet — sometimes injects window.bitkeep.ethereum
const bitkeep = w.bitkeep as { ethereum?: Eip1193Provider } | undefined; const bitkeep = w.bitkeep as { ethereum?: EthProvider } | undefined;
if (bitkeep?.ethereum) return bitkeep.ethereum; if (bitkeep?.ethereum) return bitkeep.ethereum;
// 4. Coinbase Wallet // 4. Coinbase Wallet
if (w.coinbaseWalletExtension) return w.coinbaseWalletExtension as Eip1193Provider; if (w.coinbaseWalletExtension) return w.coinbaseWalletExtension as EthProvider;
return null; return null;
} }
// Build wallet state from a provider and accounts // Build wallet state from a provider and accounts
async function buildWalletState( async function buildWalletState(
rawProvider: Eip1193Provider, rawProvider: EthProvider,
address: string address: string
): Promise<Partial<WalletState>> { ): Promise<Partial<WalletState>> {
const provider = new BrowserProvider(rawProvider); const provider = new BrowserProvider(rawProvider);
@ -125,7 +125,14 @@ async function buildWalletState(
}; };
} }
export function useWallet() { export type WalletHookReturn = WalletState & {
connect: () => Promise<{ success: boolean; error?: string }>;
connectWithProvider: (rawProvider: EthProvider, address: string) => Promise<void>;
disconnect: () => void;
switchNetwork: (chainId: number) => Promise<void>;
};
export function useWallet(): WalletHookReturn {
const [state, setState] = useState<WalletState>(INITIAL_STATE); const [state, setState] = useState<WalletState>(INITIAL_STATE);
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null); const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
@ -138,7 +145,31 @@ export function useWallet() {
}; };
}, []); }, []);
// ── Connect (explicit user action) ───────────────────────────────────────── // ── Connect via external provider (called from WalletSelector) ─────────────
// KEY FIX: WalletSelector already has the provider and address from the wallet popup.
// We update state directly without calling connect() again (which would use detectProvider()
// and might pick the wrong wallet or fail if the wallet injects to a different window property).
const connectWithProvider = useCallback(async (rawProvider: EthProvider, address: string) => {
if (!mountedRef.current) return;
setState(s => ({ ...s, isConnecting: true, error: null }));
try {
const partial = await buildWalletState(rawProvider, address);
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
} catch {
// Fallback: at minimum set address and isConnected = true
if (mountedRef.current) {
setState({
...INITIAL_STATE,
address,
shortAddress: shortenAddress(address),
isConnected: true,
isConnecting: false,
});
}
}
}, []);
// ── Connect (explicit user action via detectProvider) ──────────────────────
const connect = useCallback(async (): Promise<{ success: boolean; error?: string }> => { const connect = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const rawProvider = detectProvider(); const rawProvider = detectProvider();
@ -172,10 +203,8 @@ export function useWallet() {
let msg: string; let msg: string;
if (error?.code === 4001) { if (error?.code === 4001) {
// User rejected
msg = "已取消连接 / Connection cancelled"; msg = "已取消连接 / Connection cancelled";
} else if (error?.code === -32002) { } else if (error?.code === -32002) {
// Wallet has a pending request
msg = "钱包请求处理中,请检查钱包弹窗。如未弹出,请先完成钱包初始化设置,然后刷新页面重试。"; msg = "钱包请求处理中,请检查钱包弹窗。如未弹出,请先完成钱包初始化设置,然后刷新页面重试。";
} else if (error?.message === "no_accounts") { } else if (error?.message === "no_accounts") {
msg = "未获取到账户,请确认钱包已解锁并授权此网站。"; msg = "未获取到账户,请确认钱包已解锁并授权此网站。";
@ -228,7 +257,6 @@ export function useWallet() {
const rawProvider = detectProvider(); const rawProvider = detectProvider();
if (!rawProvider) { if (!rawProvider) {
if (attempt < 5) { if (attempt < 5) {
// Retry more times — some wallets inject later (especially mobile in-app browsers)
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 600 * attempt); retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 600 * attempt);
} }
return; return;
@ -248,7 +276,6 @@ export function useWallet() {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 800 * attempt); retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 800 * attempt);
} }
} catch { } catch {
// Silently ignore — user hasn't connected yet
if (attempt < 3) { if (attempt < 3) {
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000); retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000);
} }
@ -327,5 +354,5 @@ export function useWallet() {
}; };
}, []); }, []);
return { ...state, connect, disconnect, switchNetwork }; return { ...state, connect, connectWithProvider, disconnect, switchNetwork };
} }

View File

@ -159,12 +159,12 @@ function TRC20Panel({ usdtAmount, lang, connectedAddress, onConnectWallet }: { u
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-amber-400 text-sm"></span> <span className="text-amber-400 text-sm"></span>
<p className="text-sm font-semibold text-amber-300"> <p className="text-sm font-semibold text-amber-300">
{lang === "zh" ? "必填您的XIC接收地址BSC/ETH包地址)" : "Required: Your XIC Receiving Address (BSC/ETH wallet address)"} {lang === "zh" ? "必填您的XIC接收地址BSC/ETH包地址)" : "Required: Your XIC Receiving Address (BSC/ETH wallet address)"}
</p> </p>
</div> </div>
<p className="text-xs text-white/50"> <p className="text-xs text-white/50">
{lang === "zh" {lang === "zh"
? "XIC代币将发放到您的BSC/ETH包地址0x开头。请确保填写正确的地址否则无法收到代币。" ? "XIC代币将发放到您的BSC/ETH包地址0x开头。请确保填写正确的地址否则无法收到代币。"
: "XIC tokens will be sent to your BSC/ETH wallet address (starts with 0x). Please make sure to enter the correct address."} : "XIC tokens will be sent to your BSC/ETH wallet address (starts with 0x). Please make sure to enter the correct address."}
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
@ -364,9 +364,15 @@ function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; l
<WalletSelector <WalletSelector
lang={lang} lang={lang}
connectedAddress={wallet.address ?? undefined} connectedAddress={wallet.address ?? undefined}
onAddressDetected={(addr) => { onAddressDetected={async (addr, _network, rawProvider) => {
// WalletSelector already called eth_requestAccounts and got the address // KEY FIX: call connectWithProvider to sync wallet state immediately
// Just show success toast; wallet state will auto-update via accountsChanged event // Do NOT rely on accountsChanged event — it only fires for window.ethereum listeners
if (rawProvider) {
await wallet.connectWithProvider(rawProvider, addr);
} else {
// Manual address entry — no provider available, set address-only state
await wallet.connectWithProvider({ request: async () => [] } as unknown as import("@/hooks/useWallet").EthProvider, addr);
}
toast.success(lang === "zh" ? `已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`); toast.success(lang === "zh" ? `已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
}} }}
compact compact
@ -522,27 +528,24 @@ function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; l
: (lang === "zh" ? "无最低/最高购买限制" : "No minimum or maximum purchase limit")} : (lang === "zh" ? "无最低/最高购买限制" : "No minimum or maximum purchase limit")}
</p> </p>
{/* Add XIC to Wallet button — only show on BSC where token address is known */} {/* Add XIC to Wallet button — only show on BSC where token address is known AND wallet is connected */}
{network === "BSC" && CONTRACTS.BSC.token && ( {network === "BSC" && CONTRACTS.BSC.token && wallet.isConnected && (
<button <button
onClick={async () => { onClick={async () => {
try { try {
// Use the raw provider to call wallet_watchAsset // Use wallet.provider (ethers BrowserProvider) which wraps the connected wallet's provider
const rawProvider = (window as unknown as { ethereum?: { request: (args: { method: string; params?: unknown }) => Promise<unknown> } }).ethereum; // This works regardless of which wallet is connected (MetaMask, OKX, TokenPocket, etc.)
if (!rawProvider) { if (!wallet.provider) {
toast.error(lang === "zh" ? "未检测到钱包,请先安装 MetaMask 或其他 EVM 钱包" : "No wallet detected. Please install MetaMask or another EVM wallet."); toast.error(lang === "zh" ? "钱包未连接,请先连接钱包" : "Wallet not connected. Please connect your wallet first.");
return; return;
} }
await rawProvider.request({ await wallet.provider.send("wallet_watchAsset", {
method: "wallet_watchAsset", type: "ERC20",
params: { options: {
type: "ERC20", address: CONTRACTS.BSC.token,
options: { symbol: PRESALE_CONFIG.tokenSymbol,
address: CONTRACTS.BSC.token, decimals: PRESALE_CONFIG.tokenDecimals,
symbol: PRESALE_CONFIG.tokenSymbol, image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663287655625/Ngki3MumDNGduV3xJt3mga/nac-token-icon_382e5c30.png",
decimals: PRESALE_CONFIG.tokenDecimals,
image: "https://d2xsxph8kpxj0f.cloudfront.net/310519663287655625/Ngki3MumDNGduV3xJt3mga/nac-token-icon_382e5c30.png",
},
}, },
}); });
toast.success(lang === "zh" ? "XIC 代币已添加到钱包!" : "XIC token added to wallet!"); toast.success(lang === "zh" ? "XIC 代币已添加到钱包!" : "XIC token added to wallet!");
@ -822,11 +825,22 @@ function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookRetur
// Detect mobile browser // Detect mobile browser
const isMobile = typeof window !== "undefined" && /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isMobile = typeof window !== "undefined" && /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Detect if running inside a wallet's in-app browser (window.ethereum is injected)
const isInWalletBrowser = typeof window !== "undefined" && !!((window as unknown as Record<string, unknown>).ethereum);
// Handle connect button click — show wallet selector modal // Handle connect button click — show wallet selector modal
const handleConnectClick = async () => { const handleConnectClick = async () => {
// On mobile browsers, skip direct connect attempt and show modal immediately // On mobile AND inside a wallet's in-app browser: try direct connect first
// (mobile browsers don't support wallet extensions) // (window.ethereum is injected by the wallet app, so direct connect works)
if (isMobile && isInWalletBrowser) {
const result = await wallet.connect();
if (!result.success) {
// If direct connect failed, show modal as fallback
setShowWalletModal(true);
}
return;
}
// On mobile browser (NOT in wallet app): show modal with DeepLink guide
if (isMobile) { if (isMobile) {
setShowWalletModal(true); setShowWalletModal(true);
return; return;
@ -860,13 +874,13 @@ function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookRetur
{/* Wallet Connection Modal */} {/* Wallet Connection Modal */}
{showWalletModal && ( {showWalletModal && (
<div <div
className="fixed inset-0 z-[100] flex items-center justify-center p-4" className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center sm:p-4"
style={{ background: "rgba(0,0,0,0.85)", backdropFilter: "blur(8px)" }} style={{ background: "rgba(0,0,0,0.85)", backdropFilter: "blur(8px)" }}
onClick={(e) => { if (e.target === e.currentTarget) setShowWalletModal(false); }} onClick={(e) => { if (e.target === e.currentTarget) setShowWalletModal(false); }}
> >
<div <div
className="w-full max-w-md rounded-2xl p-6 relative" className="w-full sm:max-w-md rounded-t-2xl sm:rounded-2xl p-5 relative overflow-y-auto"
style={{ background: "rgba(10,10,20,0.98)", border: "1px solid rgba(240,180,41,0.3)", boxShadow: "0 0 40px rgba(240,180,41,0.15)" }} style={{ background: "rgba(10,10,20,0.98)", border: "1px solid rgba(240,180,41,0.3)", boxShadow: "0 0 40px rgba(240,180,41,0.15)", maxHeight: "85vh" }}
> >
{/* Close button */} {/* Close button */}
<button <button
@ -881,38 +895,50 @@ function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookRetur
<h3 className="text-lg font-bold text-white mb-1" style={{ fontFamily: "'Space Grotesk', sans-serif" }}> <h3 className="text-lg font-bold text-white mb-1" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
{lang === "zh" ? "连接钱包" : "Connect Wallet"} {lang === "zh" ? "连接钱包" : "Connect Wallet"}
</h3> </h3>
<p className="text-xs text-white/40 mb-4"> {!isMobile && (
{lang === "zh" <p className="text-xs text-white/40 mb-4">
? "选择您的钱包进行连接,或手动输入地址"
: "Select your wallet to connect, or enter address manually"}
</p>
{/* MetaMask initialization guide */}
<div
className="rounded-xl p-3 mb-4"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.2)" }}
>
<p className="text-xs text-amber-300/80 leading-relaxed">
{lang === "zh" {lang === "zh"
? "💡 首次使用 MetaMask请先打开 MetaMask 扩展完成初始化(创建或导入钱包),完成后点击下方「刷新」按钮重新检测。" ? "选择您的钱包进行连接,或手动输入地址"
: "💡 First time using MetaMask? Open the MetaMask extension and complete setup (create or import a wallet), then click Refresh below to re-detect."} : "Select your wallet to connect, or enter address manually"}
</p> </p>
</div> )}
{/* MetaMask initialization guide — desktop only */}
{!isMobile && (
<div
className="rounded-xl p-3 mb-4"
style={{ background: "rgba(240,180,41,0.06)", border: "1px solid rgba(240,180,41,0.2)" }}
>
<p className="text-xs text-amber-300/80 leading-relaxed">
{lang === "zh"
? "💡 首次使用 MetaMask请先打开 MetaMask 扩展完成初始化(创建或导入钱包),完成后点击下方「刷新」按钮重新检测。"
: "💡 First time using MetaMask? Open the MetaMask extension and complete setup (create or import a wallet), then click Refresh below to re-detect."}
</p>
</div>
)}
{isMobile && <div className="mb-3" />}
<WalletSelector <WalletSelector
lang={lang} lang={lang}
connectedAddress={wallet.address ?? undefined} compact={false}
onAddressDetected={async (addr) => { showTron={false}
// After address detected from WalletSelector, sync wallet state connectedAddress={undefined}
const result = await wallet.connect(); onAddressDetected={async (addr, _network, rawProvider) => {
if (result.success) { // KEY FIX: use connectWithProvider() to directly sync wallet state
setShowWalletModal(false); // This avoids calling connect() again which would use detectProvider()
toast.success(lang === "zh" ? `钱包已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Wallet connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`); // and might fail if the wallet injects to a different window property (e.g. OKX -> window.okxwallet)
if (rawProvider) {
await wallet.connectWithProvider(rawProvider, addr);
} else { } else {
// Even if connect() failed, we have the address — close modal // Fallback for manual address entry (no provider)
setShowWalletModal(false); const result = await wallet.connect();
toast.success(lang === "zh" ? `地址已确认: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Address confirmed: ${addr.slice(0, 6)}...${addr.slice(-4)}`); if (!result.success) {
// Still mark as connected with address only
await wallet.connectWithProvider({ request: async () => [] } as unknown as import("@/hooks/useWallet").EthProvider, addr);
}
} }
setShowWalletModal(false);
toast.success(lang === "zh" ? `钱包已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Wallet connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
}} }}
/> />
</div> </div>

View File

@ -71,3 +71,10 @@
- [x] 创建 WalletSelector 组件MetaMask、Trust Wallet、OKX、Coinbase、TokenPocket 检测+连接+安装引导) - [x] 创建 WalletSelector 组件MetaMask、Trust Wallet、OKX、Coinbase、TokenPocket 检测+连接+安装引导)
- [x] 集成 WalletSelector 到 TRON 标签 XIC 接收地址区域- [x] 集成 WalletSelector 到 BSC/ETH 购买面板替换原 Connect Wallet 按鈕钮 - [x] 集成 WalletSelector 到 TRON 标签 XIC 接收地址区域- [x] 集成 WalletSelector 到 BSC/ETH 购买面板替换原 Connect Wallet 按鈕钮
- [x] 构建并部署到备份服务器 - [x] 构建并部署到备份服务器
## v8 UI设计错误修复
- [ ] 修复图1钱包选择器弹窗同时显示"已安装"和"未安装"钱包,界面混乱 → 有已安装钱包时隐藏未安装列表
- [ ] 修复图2点击钱包后选择器面板折叠缩小 → 连接中状态应保持面板展开显示loading状态
- [ ] 修复图3"添加XIC到钱包"按钮在未连接钱包时显示并报错 → 未连接时隐藏该按钮
- [ ] 构建并部署到备份服务器
- [ ] 同步到Gitea代码库