Compare commits
6 Commits
45e1f886aa
...
158822556f
| Author | SHA1 | Date |
|---|---|---|
|
|
158822556f | |
|
|
81c9b2544f | |
|
|
809b6327b8 | |
|
|
4e5743512c | |
|
|
133aaedb68 | |
|
|
40be4636e9 |
|
|
@ -0,0 +1,522 @@
|
|||
// NAC XIC Presale — Wallet Selector Component
|
||||
// Detects installed EVM wallets and shows connect/install buttons for each
|
||||
// v2: improved detection timing, refresh button, manual address fallback
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type Lang = "zh" | "en";
|
||||
|
||||
interface WalletInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
installUrl: string;
|
||||
isInstalled: () => boolean;
|
||||
connect: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
interface WalletSelectorProps {
|
||||
lang: Lang;
|
||||
onAddressDetected: (address: string) => void;
|
||||
connectedAddress?: string;
|
||||
compact?: boolean; // compact mode for BSC/ETH panel
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
|
||||
// ── Provider detection helpers ────────────────────────────────────────────────
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Find a specific provider from the providers array or direct injection
|
||||
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;
|
||||
}
|
||||
|
||||
async function requestAccounts(provider: EthProvider): Promise<string | null> {
|
||||
try {
|
||||
const accounts = await provider.request({ method: "eth_requestAccounts" }) as string[];
|
||||
return accounts?.[0] ?? null;
|
||||
} catch (err: unknown) {
|
||||
const error = err as { code?: number; message?: string };
|
||||
// User rejected
|
||||
if (error?.code === 4001) throw new Error("user_rejected");
|
||||
// MetaMask not initialized / locked
|
||||
if (error?.code === -32002) throw new Error("wallet_pending");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if MetaMask is installed but not yet initialized (no accounts, no unlock)
|
||||
async function isWalletInitialized(provider: EthProvider): Promise<boolean> {
|
||||
try {
|
||||
const accounts = await provider.request({ method: "eth_accounts" }) as string[];
|
||||
// If we can get accounts (even empty array), wallet is initialized
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wallet definitions ────────────────────────────────────────────────────────
|
||||
|
||||
function buildWallets(): WalletInfo[] {
|
||||
return [
|
||||
{
|
||||
id: "metamask",
|
||||
name: "MetaMask",
|
||||
icon: <MetaMaskIcon />,
|
||||
installUrl: "https://metamask.io/download/",
|
||||
isInstalled: () => !!findProvider(p => !!p.isMetaMask),
|
||||
connect: async () => {
|
||||
const p = findProvider(p => !!p.isMetaMask) ?? getEth();
|
||||
return p ? requestAccounts(p) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "trust",
|
||||
name: "Trust Wallet",
|
||||
icon: <TrustWalletIcon />,
|
||||
installUrl: "https://trustwallet.com/download",
|
||||
isInstalled: () => !!findProvider(p => !!(p.isTrust || p.isTrustWallet)),
|
||||
connect: async () => {
|
||||
const p = findProvider(p => !!(p.isTrust || p.isTrustWallet)) ?? getEth();
|
||||
return p ? requestAccounts(p) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "okx",
|
||||
name: "OKX Wallet",
|
||||
icon: <OKXIcon />,
|
||||
installUrl: "https://www.okx.com/web3",
|
||||
isInstalled: () => !!(getOKX() || findProvider(p => !!(p.isOKExWallet || p.isOkxWallet))),
|
||||
connect: async () => {
|
||||
const p = getOKX() ?? findProvider(p => !!(p.isOKExWallet || p.isOkxWallet));
|
||||
return p ? requestAccounts(p) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
},
|
||||
},
|
||||
{
|
||||
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;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Validate Ethereum address format
|
||||
function isValidEthAddress(addr: string): boolean {
|
||||
return /^0x[0-9a-fA-F]{40}$/.test(addr);
|
||||
}
|
||||
|
||||
// ── WalletSelector Component ──────────────────────────────────────────────────
|
||||
|
||||
export function WalletSelector({ lang, onAddressDetected, connectedAddress, compact = false }: WalletSelectorProps) {
|
||||
const [wallets, setWallets] = useState<WalletInfo[]>([]);
|
||||
const [connecting, setConnecting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detecting, setDetecting] = useState(true);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [manualAddress, setManualAddress] = useState("");
|
||||
const [manualError, setManualError] = useState<string | null>(null);
|
||||
|
||||
const detectWallets = useCallback(() => {
|
||||
setDetecting(true);
|
||||
setError(null);
|
||||
// Wait for wallet extensions to fully inject (up to 1500ms)
|
||||
const timer = setTimeout(() => {
|
||||
setWallets(buildWallets());
|
||||
setDetecting(false);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = detectWallets();
|
||||
return cleanup;
|
||||
}, [detectWallets]);
|
||||
|
||||
const handleConnect = async (wallet: WalletInfo) => {
|
||||
setConnecting(wallet.id);
|
||||
setError(null);
|
||||
try {
|
||||
const address = await wallet.connect();
|
||||
if (address) {
|
||||
onAddressDetected(address);
|
||||
} else {
|
||||
setError(lang === "zh" ? "未获取到地址,请重试" : "No address returned, please try again");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error;
|
||||
if (error.message === "user_rejected") {
|
||||
setError(lang === "zh" ? "已取消连接" : "Connection cancelled");
|
||||
} else if (error.message === "wallet_pending") {
|
||||
setError(lang === "zh" ? "钱包请求处理中,请检查钱包弹窗" : "Wallet request pending, please check your wallet popup");
|
||||
} else if (error.message?.includes("not initialized") || error.message?.includes("setup")) {
|
||||
setError(lang === "zh"
|
||||
? "请先完成钱包初始化设置,然后刷新页面重试"
|
||||
: "Please complete wallet setup first, then refresh the page");
|
||||
} else {
|
||||
setError(lang === "zh" ? "连接失败,请重试" : "Connection failed, please try again");
|
||||
}
|
||||
} finally {
|
||||
setConnecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualSubmit = () => {
|
||||
const addr = manualAddress.trim();
|
||||
if (!addr) {
|
||||
setManualError(lang === "zh" ? "请输入钱包地址" : "Please enter wallet address");
|
||||
return;
|
||||
}
|
||||
if (!isValidEthAddress(addr)) {
|
||||
setManualError(lang === "zh" ? "地址格式无效,请输入正确的以太坊地址(0x开头,42位)" : "Invalid address format. Must be 0x followed by 40 hex characters");
|
||||
return;
|
||||
}
|
||||
setManualError(null);
|
||||
onAddressDetected(addr);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 auto-fill address"}
|
||||
</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>
|
||||
|
||||
{/* Loading state */}
|
||||
{detecting && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Installed wallets */}
|
||||
{!detecting && installedWallets.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{installedWallets.map(wallet => (
|
||||
<button
|
||||
key={wallet.id}
|
||||
onClick={() => handleConnect(wallet)}
|
||||
disabled={connecting === wallet.id}
|
||||
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(0,212,255,0.08)",
|
||||
border: "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={{ background: "rgba(0,212,255,0.15)", color: "#00d4ff" }}
|
||||
>
|
||||
{lang === "zh" ? "已安装" : "Installed"}
|
||||
</span>
|
||||
{connecting === wallet.id ? (
|
||||
<svg className="animate-spin w-4 h-4 text-white/60 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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* No wallets installed */}
|
||||
{!detecting && 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" ? "未检测到 EVM 钱包" : "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>
|
||||
<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 — show install links */}
|
||||
{!detecting && !compact && notInstalledWallets.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-white/30 mt-2">
|
||||
{lang === "zh" ? "未安装(点击安装)" : "Not installed (click to install)"}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{notInstalledWallets.map(wallet => (
|
||||
<a
|
||||
key={wallet.id}
|
||||
href={wallet.installUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-col items-center gap-1.5 p-2.5 rounded-xl transition-all hover:opacity-80"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<span className="opacity-40">{wallet.icon}</span>
|
||||
<span className="text-xs text-white/30 text-center leading-tight">{wallet.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In compact mode, show install links inline */}
|
||||
{!detecting && compact && notInstalledWallets.length > 0 && installedWallets.length === 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{notInstalledWallets.slice(0, 4).map(wallet => (
|
||||
<a
|
||||
key={wallet.id}
|
||||
href={wallet.installUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs transition-all hover:opacity-80"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
color: "rgba(255,255,255,0.4)",
|
||||
}}
|
||||
>
|
||||
<span className="opacity-50">{wallet.icon}</span>
|
||||
{lang === "zh" ? `安装 ${wallet.name}` : `Install ${wallet.name}`}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 text-center">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Manual address input — divider */}
|
||||
<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 开头)"
|
||||
: "Enter your EVM wallet address (starts with 0x)"}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={manualAddress}
|
||||
onChange={e => { setManualAddress(e.target.value); setManualError(null); }}
|
||||
placeholder={lang === "zh" ? "0x..." : "0x..."}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
// NAC XIC Presale — Wallet Connection Hook
|
||||
// Supports MetaMask / any EVM-compatible wallet (BSC + ETH)
|
||||
// Supports MetaMask, Trust Wallet, OKX Wallet, Coinbase Wallet, and all EVM-compatible wallets
|
||||
// Robust auto-detect with retry, multi-provider support, and graceful fallback
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BrowserProvider, JsonRpcSigner, Eip1193Provider } from "ethers";
|
||||
import { shortenAddress, switchToNetwork } from "@/lib/contracts";
|
||||
|
||||
|
|
@ -29,87 +30,246 @@ const INITIAL_STATE: WalletState = {
|
|||
error: null,
|
||||
};
|
||||
|
||||
// Detect the best available EVM provider across all major wallets
|
||||
function detectProvider(): Eip1193Provider | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
// Check for multiple injected providers (e.g., MetaMask + Coinbase both installed)
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const eth = w.ethereum as (Eip1193Provider & { providers?: Eip1193Provider[]; isMetaMask?: boolean; isTrust?: boolean; isOKExWallet?: boolean; isCoinbaseWallet?: boolean }) | undefined;
|
||||
|
||||
if (!eth) {
|
||||
// Fallback: check wallet-specific globals
|
||||
if (w.okxwallet) return w.okxwallet as Eip1193Provider;
|
||||
if (w.coinbaseWalletExtension) return w.coinbaseWalletExtension as Eip1193Provider;
|
||||
return null;
|
||||
}
|
||||
|
||||
// If multiple providers are injected (common when multiple extensions installed)
|
||||
if (eth.providers && Array.isArray(eth.providers) && eth.providers.length > 0) {
|
||||
// Prefer MetaMask if available, otherwise use first provider
|
||||
const metamask = eth.providers.find((p: Eip1193Provider & { isMetaMask?: boolean }) => p.isMetaMask);
|
||||
return metamask ?? eth.providers[0];
|
||||
}
|
||||
|
||||
return eth;
|
||||
}
|
||||
|
||||
// Build wallet state from a provider and accounts
|
||||
async function buildWalletState(
|
||||
rawProvider: Eip1193Provider,
|
||||
address: string
|
||||
): Promise<Partial<WalletState>> {
|
||||
const provider = new BrowserProvider(rawProvider);
|
||||
let chainId: number | null = null;
|
||||
let signer: JsonRpcSigner | null = null;
|
||||
|
||||
try {
|
||||
const network = await provider.getNetwork();
|
||||
chainId = Number(network.chainId);
|
||||
} catch {
|
||||
// Some wallets don't support getNetwork immediately — try eth_chainId directly
|
||||
try {
|
||||
const chainHex = await (rawProvider as { request: (args: { method: string }) => Promise<string> }).request({ method: "eth_chainId" });
|
||||
chainId = parseInt(chainHex, 16);
|
||||
} catch {
|
||||
chainId = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
signer = await provider.getSigner();
|
||||
} catch {
|
||||
// getSigner may fail on some wallets before full connection — that's OK
|
||||
signer = null;
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
shortAddress: shortenAddress(address),
|
||||
isConnected: true,
|
||||
chainId,
|
||||
provider,
|
||||
signer,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWallet() {
|
||||
const [state, setState] = useState<WalletState>(INITIAL_STATE);
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (retryRef.current) clearTimeout(retryRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Connect (explicit user action) ─────────────────────────────────────────
|
||||
const connect = useCallback(async () => {
|
||||
if (!window.ethereum) {
|
||||
setState(s => ({ ...s, error: "Please install MetaMask or a compatible wallet." }));
|
||||
const rawProvider = detectProvider();
|
||||
if (!rawProvider) {
|
||||
setState(s => ({ ...s, error: "请安装 MetaMask 或其他 EVM 兼容钱包 / Please install MetaMask or a compatible wallet." }));
|
||||
return;
|
||||
}
|
||||
setState(s => ({ ...s, isConnecting: true, error: null }));
|
||||
try {
|
||||
const provider = new BrowserProvider(window.ethereum as Eip1193Provider);
|
||||
const accounts = await provider.send("eth_requestAccounts", []);
|
||||
const network = await provider.getNetwork();
|
||||
const signer = await provider.getSigner();
|
||||
const address = accounts[0] as string;
|
||||
setState({
|
||||
address,
|
||||
shortAddress: shortenAddress(address),
|
||||
isConnected: true,
|
||||
chainId: Number(network.chainId),
|
||||
provider,
|
||||
signer,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
// Request accounts — this triggers the wallet popup
|
||||
const accounts = await (rawProvider as { request: (args: { method: string; params?: unknown[] }) => Promise<string[]> }).request({
|
||||
method: "eth_requestAccounts",
|
||||
params: [],
|
||||
});
|
||||
if (!accounts || accounts.length === 0) throw new Error("No accounts returned");
|
||||
const partial = await buildWalletState(rawProvider, accounts[0]);
|
||||
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
|
||||
} catch (err: unknown) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
isConnecting: false,
|
||||
error: (err as Error).message || "Failed to connect wallet",
|
||||
}));
|
||||
const msg = (err as Error).message || "Failed to connect wallet";
|
||||
if (mountedRef.current) setState(s => ({ ...s, isConnecting: false, error: msg }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Disconnect ──────────────────────────────────────────────────────────────
|
||||
const disconnect = useCallback(() => {
|
||||
setState(INITIAL_STATE);
|
||||
}, []);
|
||||
|
||||
// ── Switch Network ──────────────────────────────────────────────────────────
|
||||
const switchNetwork = useCallback(async (chainId: number) => {
|
||||
try {
|
||||
await switchToNetwork(chainId);
|
||||
if (window.ethereum) {
|
||||
const provider = new BrowserProvider(window.ethereum as Eip1193Provider);
|
||||
const rawProvider = detectProvider();
|
||||
if (rawProvider) {
|
||||
const provider = new BrowserProvider(rawProvider);
|
||||
const network = await provider.getNetwork();
|
||||
const signer = await provider.getSigner();
|
||||
setState(s => ({
|
||||
...s,
|
||||
chainId: Number(network.chainId),
|
||||
provider,
|
||||
signer,
|
||||
error: null,
|
||||
}));
|
||||
let signer: JsonRpcSigner | null = null;
|
||||
try { signer = await provider.getSigner(); } catch { /* ignore */ }
|
||||
if (mountedRef.current) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
chainId: Number(network.chainId),
|
||||
provider,
|
||||
signer,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setState(s => ({ ...s, error: (err as Error).message }));
|
||||
if (mountedRef.current) setState(s => ({ ...s, error: (err as Error).message }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for account/chain changes
|
||||
// ── Auto-detect on page load (silent, no popup) ─────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!window.ethereum) return;
|
||||
const handleAccountsChanged = (accounts: unknown) => {
|
||||
const accs = accounts as string[];
|
||||
if (accs.length === 0) {
|
||||
setState(INITIAL_STATE);
|
||||
} else {
|
||||
setState(s => ({
|
||||
...s,
|
||||
address: accs[0],
|
||||
shortAddress: shortenAddress(accs[0]),
|
||||
}));
|
||||
let cancelled = false;
|
||||
|
||||
const tryAutoDetect = async (attempt: number) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const rawProvider = detectProvider();
|
||||
if (!rawProvider) {
|
||||
// Wallet extension may not be injected yet — retry up to 3 times
|
||||
if (attempt < 3) {
|
||||
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 800 * attempt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = await (rawProvider as { request: (args: { method: string }) => Promise<string[]> }).request({
|
||||
method: "eth_accounts", // Silent — no popup
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (accounts && accounts.length > 0) {
|
||||
const partial = await buildWalletState(rawProvider, accounts[0]);
|
||||
if (!cancelled && mountedRef.current) {
|
||||
setState({ ...INITIAL_STATE, ...partial });
|
||||
}
|
||||
} else if (attempt < 3) {
|
||||
// Accounts empty — wallet might not have finished loading, retry
|
||||
retryRef.current = setTimeout(() => tryAutoDetect(attempt + 1), 1000 * attempt);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — user hasn't connected yet
|
||||
}
|
||||
};
|
||||
const handleChainChanged = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
window.ethereum.on("accountsChanged", handleAccountsChanged);
|
||||
window.ethereum.on("chainChanged", handleChainChanged);
|
||||
|
||||
// Small initial delay to let wallet extensions inject themselves
|
||||
retryRef.current = setTimeout(() => tryAutoDetect(1), 300);
|
||||
|
||||
return () => {
|
||||
window.ethereum?.removeListener("accountsChanged", handleAccountsChanged);
|
||||
window.ethereum?.removeListener("chainChanged", handleChainChanged);
|
||||
cancelled = true;
|
||||
if (retryRef.current) clearTimeout(retryRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Listen for account / chain changes ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const rawProvider = detectProvider();
|
||||
if (!rawProvider) return;
|
||||
|
||||
const eth = rawProvider as {
|
||||
on?: (event: string, handler: (data: unknown) => void) => void;
|
||||
removeListener?: (event: string, handler: (data: unknown) => void) => void;
|
||||
};
|
||||
if (!eth.on) return;
|
||||
|
||||
const handleAccountsChanged = async (accounts: unknown) => {
|
||||
const accs = accounts as string[];
|
||||
if (!mountedRef.current) return;
|
||||
if (!accs || accs.length === 0) {
|
||||
setState(INITIAL_STATE);
|
||||
} else {
|
||||
// Re-build full state with new address
|
||||
try {
|
||||
const partial = await buildWalletState(rawProvider, accs[0]);
|
||||
if (mountedRef.current) setState({ ...INITIAL_STATE, ...partial });
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
address: accs[0],
|
||||
shortAddress: shortenAddress(accs[0]),
|
||||
isConnected: true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChainChanged = async () => {
|
||||
if (!mountedRef.current) return;
|
||||
// Re-fetch network info instead of reloading the page
|
||||
try {
|
||||
const provider = new BrowserProvider(rawProvider);
|
||||
const network = await provider.getNetwork();
|
||||
let signer: JsonRpcSigner | null = null;
|
||||
try { signer = await provider.getSigner(); } catch { /* ignore */ }
|
||||
if (mountedRef.current) {
|
||||
setState(s => ({
|
||||
...s,
|
||||
chainId: Number(network.chainId),
|
||||
provider,
|
||||
signer,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// If we can't get network info, reload as last resort
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
eth.on("accountsChanged", handleAccountsChanged);
|
||||
eth.on("chainChanged", handleChainChanged);
|
||||
|
||||
return () => {
|
||||
if (eth.removeListener) {
|
||||
eth.removeListener("accountsChanged", handleAccountsChanged);
|
||||
eth.removeListener("chainChanged", handleChainChanged);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -119,13 +119,316 @@ function LoginForm({ onLogin }: { onLogin: (token: string) => void }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Main Dashboard ───────────────────────────────────────────────────────────
|
||||
// ─── Main D// ─── Settings Panel ───────────────────────────────────────────────
|
||||
function SettingsPanel({ token }: { token: string }) {
|
||||
const { data: configData, refetch: refetchConfig, isLoading } = trpc.admin.getConfig.useQuery({ token });
|
||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [savedKeys, setSavedKeys] = useState<Set<string>>(new Set());
|
||||
const [telegramBotToken, setTelegramBotToken] = useState("");
|
||||
const [telegramChatId, setTelegramChatId] = useState("");
|
||||
const [telegramStatus, setTelegramStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [telegramError, setTelegramError] = useState("");
|
||||
|
||||
// ── Presale Active/Paused Toggle ──────────────────────────────────────────
|
||||
const isPresaleLive = (configData?.find(c => c.key === "presaleStatus")?.value ?? "live") === "live";
|
||||
const [togglingPresale, setTogglingPresale] = useState(false);
|
||||
|
||||
const setConfigMutation = trpc.admin.setConfig.useMutation({
|
||||
onSuccess: (_, vars) => {
|
||||
setSavedKeys(prev => { const s = new Set(Array.from(prev)); s.add(vars.key); return s; });
|
||||
setSavingKey(null);
|
||||
setTogglingPresale(false);
|
||||
refetchConfig();
|
||||
setTimeout(() => setSavedKeys(prev => { const n = new Set(Array.from(prev)); n.delete(vars.key); return n; }), 2000);
|
||||
},
|
||||
onError: (err) => {
|
||||
setSavingKey(null);
|
||||
setTogglingPresale(false);
|
||||
alert(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTogglePresale = () => {
|
||||
const newStatus = isPresaleLive ? "paused" : "live";
|
||||
setTogglingPresale(true);
|
||||
setConfigMutation.mutate({ token, key: "presaleStatus", value: newStatus });
|
||||
};
|
||||
|
||||
const testTelegramMutation = trpc.admin.testTelegram.useMutation({
|
||||
onSuccess: () => {
|
||||
setTelegramStatus("success");
|
||||
refetchConfig();
|
||||
},
|
||||
onError: (err) => {
|
||||
setTelegramStatus("error");
|
||||
setTelegramError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize edit values from config
|
||||
useEffect(() => {
|
||||
if (configData) {
|
||||
const vals: Record<string, string> = {};
|
||||
configData.forEach(c => { vals[c.key] = c.value; });
|
||||
setEditValues(vals);
|
||||
// Pre-fill Telegram fields
|
||||
const botToken = configData.find(c => c.key === "telegramBotToken")?.value || "";
|
||||
const chatId = configData.find(c => c.key === "telegramChatId")?.value || "";
|
||||
if (botToken) setTelegramBotToken(botToken);
|
||||
if (chatId) setTelegramChatId(chatId);
|
||||
}
|
||||
}, [configData]);
|
||||
|
||||
const handleSave = (key: string) => {
|
||||
setSavingKey(key);
|
||||
setConfigMutation.mutate({ token, key, value: editValues[key] || "" });
|
||||
};
|
||||
|
||||
const handleTestTelegram = () => {
|
||||
if (!telegramBotToken || !telegramChatId) {
|
||||
setTelegramStatus("error");
|
||||
setTelegramError("Please enter both Bot Token and Chat ID");
|
||||
return;
|
||||
}
|
||||
setTelegramStatus("testing");
|
||||
setTelegramError("");
|
||||
testTelegramMutation.mutate({ token, botToken: telegramBotToken, chatId: telegramChatId });
|
||||
};
|
||||
|
||||
// Group configs by category
|
||||
const presaleKeys = ["presaleEndDate", "tokenPrice", "hardCap", "listingPrice", "totalSupply", "maxPurchaseUsdt", "presaleStatus"];
|
||||
const contentKeys = ["heroTitle", "heroSubtitle", "tronReceivingAddress"];
|
||||
const telegramKeys = ["telegramBotToken", "telegramChatId"];
|
||||
|
||||
const renderConfigRow = (cfg: { key: string; value: string; label: string; description: string; type: string; updatedAt: Date | null }) => (
|
||||
<div key={cfg.key} className="rounded-xl p-4 mb-3" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-white/80">{cfg.label}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: "rgba(255,255,255,0.06)", color: "rgba(255,255,255,0.4)" }}>{cfg.type}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/40 mb-2">{cfg.description}</p>
|
||||
{cfg.type === "text" && cfg.key !== "heroSubtitle" ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editValues[cfg.key] ?? cfg.value}
|
||||
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
) : cfg.key === "heroSubtitle" ? (
|
||||
<textarea
|
||||
value={editValues[cfg.key] ?? cfg.value}
|
||||
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm resize-none"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
) : cfg.type === "number" ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editValues[cfg.key] ?? cfg.value}
|
||||
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
) : cfg.type === "date" ? (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editValues[cfg.key] ? editValues[cfg.key].replace("Z", "").slice(0, 16) : ""}
|
||||
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value + ":00Z" }))}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={editValues[cfg.key] ?? cfg.value}
|
||||
onChange={e => setEditValues(prev => ({ ...prev, [cfg.key]: e.target.value }))}
|
||||
className="w-full px-3 py-2 rounded-lg text-sm"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleSave(cfg.key)}
|
||||
disabled={savingKey === cfg.key}
|
||||
className="px-4 py-2 rounded-lg text-xs font-semibold transition-all whitespace-nowrap"
|
||||
style={{
|
||||
background: savedKeys.has(cfg.key) ? "rgba(0,230,118,0.2)" : "rgba(240,180,41,0.15)",
|
||||
border: savedKeys.has(cfg.key) ? "1px solid rgba(0,230,118,0.4)" : "1px solid rgba(240,180,41,0.3)",
|
||||
color: savedKeys.has(cfg.key) ? "#00e676" : "#f0b429",
|
||||
}}
|
||||
>
|
||||
{savingKey === cfg.key ? "Saving..." : savedKeys.has(cfg.key) ? "✓ Saved" : "Save"}
|
||||
</button>
|
||||
{cfg.updatedAt && (
|
||||
<span className="text-xs text-white/25">{new Date(cfg.updatedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-12 text-white/40">Loading settings...</div>;
|
||||
}
|
||||
|
||||
const getConfigItem = (key: string) => configData?.find(c => c.key === key);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Presale Active Toggle ── */}
|
||||
<div
|
||||
className="rounded-2xl p-6"
|
||||
style={{
|
||||
background: isPresaleLive ? "rgba(0,230,118,0.06)" : "rgba(255,60,60,0.06)",
|
||||
border: isPresaleLive ? "2px solid rgba(0,230,118,0.45)" : "2px solid rgba(255,60,60,0.45)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
background: isPresaleLive ? "#00e676" : "#ff4444",
|
||||
boxShadow: isPresaleLive ? "0 0 8px rgba(0,230,118,0.8)" : "0 0 8px rgba(255,68,68,0.8)",
|
||||
animation: isPresaleLive ? "pulse 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
<h3
|
||||
className="text-lg font-bold"
|
||||
style={{
|
||||
color: isPresaleLive ? "#00e676" : "#ff6060",
|
||||
fontFamily: "'Space Grotesk', sans-serif",
|
||||
}}
|
||||
>
|
||||
{isPresaleLive ? "预售进行中 PRESALE LIVE" : "预售已暂停 PRESALE PAUSED"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs ml-6" style={{ color: "rgba(255,255,255,0.45)" }}>
|
||||
{isPresaleLive
|
||||
? "用户当前可正常购买 XIC 代币。点击《暂停预售》可立即封禁所有购买入口。"
|
||||
: "预售已暂停,首页购买按钮已禁用。点击《开启预售》可重新开放购买。"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTogglePresale}
|
||||
disabled={togglingPresale}
|
||||
className="flex-shrink-0 px-8 py-3 rounded-xl font-bold text-base transition-all"
|
||||
style={{
|
||||
background: isPresaleLive
|
||||
? "linear-gradient(135deg, rgba(255,60,60,0.25) 0%, rgba(255,60,60,0.15) 100%)"
|
||||
: "linear-gradient(135deg, rgba(0,230,118,0.25) 0%, rgba(0,230,118,0.15) 100%)",
|
||||
border: isPresaleLive ? "1.5px solid rgba(255,60,60,0.6)" : "1.5px solid rgba(0,230,118,0.6)",
|
||||
color: isPresaleLive ? "#ff6060" : "#00e676",
|
||||
fontFamily: "'Space Grotesk', sans-serif",
|
||||
opacity: togglingPresale ? 0.6 : 1,
|
||||
cursor: togglingPresale ? "not-allowed" : "pointer",
|
||||
letterSpacing: "0.03em",
|
||||
}}
|
||||
>
|
||||
{togglingPresale
|
||||
? "处理中..."
|
||||
: isPresaleLive
|
||||
? "⏸ 暂停预售"
|
||||
: "▶ 开启预售"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presale Parameters */}
|
||||
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(240,180,41,0.15)" }}>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: "#f0b429" }}>Presale Parameters 预售参数</h3>
|
||||
{presaleKeys.map(key => {
|
||||
const cfg = getConfigItem(key);
|
||||
if (!cfg) return null;
|
||||
return renderConfigRow(cfg);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Site Content */}
|
||||
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(0,212,255,0.15)" }}>
|
||||
<h3 className="text-sm font-semibold mb-4" style={{ color: "#00d4ff" }}>Site Content 首页内容</h3>
|
||||
{contentKeys.map(key => {
|
||||
const cfg = getConfigItem(key);
|
||||
if (!cfg) return null;
|
||||
return renderConfigRow(cfg);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Telegram Notifications */}
|
||||
<div className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(0,230,118,0.15)" }}>
|
||||
<h3 className="text-sm font-semibold mb-1" style={{ color: "#00e676" }}>Telegram Notifications</h3>
|
||||
<p className="text-xs text-white/40 mb-4">
|
||||
Set up Telegram Bot to receive instant alerts when new TRC20 purchases are confirmed.
|
||||
Get your Bot Token from <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer" style={{ color: "#00d4ff" }}>@BotFather</a>.
|
||||
</p>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div>
|
||||
<label className="text-xs text-white/60 block mb-1">Bot Token (from @BotFather)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={telegramBotToken}
|
||||
onChange={e => setTelegramBotToken(e.target.value)}
|
||||
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-mono"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-white/60 block mb-1">Chat ID (personal or group)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={telegramChatId}
|
||||
onChange={e => setTelegramChatId(e.target.value)}
|
||||
placeholder="-1001234567890 or 123456789"
|
||||
className="w-full px-3 py-2 rounded-lg text-sm font-mono"
|
||||
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white", outline: "none" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleTestTelegram}
|
||||
disabled={telegramStatus === "testing"}
|
||||
className="px-5 py-2 rounded-xl text-sm font-semibold transition-all"
|
||||
style={{
|
||||
background: telegramStatus === "success" ? "rgba(0,230,118,0.2)" : "rgba(0,230,118,0.1)",
|
||||
border: telegramStatus === "success" ? "1px solid rgba(0,230,118,0.5)" : "1px solid rgba(0,230,118,0.3)",
|
||||
color: "#00e676",
|
||||
}}
|
||||
>
|
||||
{telegramStatus === "testing" ? "Sending test..." : telegramStatus === "success" ? "✓ Connected & Saved!" : "Test & Save Connection"}
|
||||
</button>
|
||||
{telegramStatus === "error" && (
|
||||
<span className="text-xs text-red-400">{telegramError}</span>
|
||||
)}
|
||||
{telegramStatus === "success" && (
|
||||
<span className="text-xs text-green-400">Test message sent! Check your Telegram.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg p-3 text-xs text-white/50" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)" }}>
|
||||
<p className="font-semibold text-white/70 mb-1">How to get Chat ID:</p>
|
||||
<p>1. Start a chat with your bot (send any message)</p>
|
||||
<p>2. Visit: <code className="text-cyan-400">https://api.telegram.org/bot<TOKEN>/getUpdates</code></p>
|
||||
<p>3. Find <code className="text-cyan-400">{'{"chat":{"id": YOUR_CHAT_ID}}'}</code> in the response</p> </div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Dashboard ─────────────────────────────────────────────
|
||||
function Dashboard({ token, onLogout }: { token: string; onLogout: () => void }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "pending" | "confirmed" | "distributed" | "failed">("all");
|
||||
const [markingId, setMarkingId] = useState<number | null>(null);
|
||||
const [distributeTxInput, setDistributeTxInput] = useState<Record<number, string>>({});
|
||||
const [activeTab, setActiveTab] = useState<"purchases" | "intents">("purchases");
|
||||
const [activeTab, setActiveTab] = useState<"purchases" | "intents" | "settings">("purchases");
|
||||
|
||||
const { data: statsData, refetch: refetchStats } = trpc.admin.stats.useQuery({ token });
|
||||
const { data: intentsData, isLoading: intentsLoading } = trpc.admin.listIntents.useQuery({ token, showAll: false });
|
||||
|
|
@ -294,6 +597,17 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
|
|||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("settings")}
|
||||
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
|
||||
style={{
|
||||
background: activeTab === "settings" ? "rgba(0,230,118,0.15)" : "rgba(255,255,255,0.04)",
|
||||
border: activeTab === "settings" ? "1px solid rgba(0,230,118,0.4)" : "1px solid rgba(255,255,255,0.08)",
|
||||
color: activeTab === "settings" ? "#00e676" : "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
⚙️ Site Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── EVM Intents Table ── */}
|
||||
|
|
@ -519,7 +833,13 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Site Settings Panel ── */}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsPanel token={token} />
|
||||
)}
|
||||
|
||||
{/* ── Instructions ── */}
|
||||
{activeTab !== "settings" && (
|
||||
<div className="mt-6 rounded-2xl p-5" style={{ background: "rgba(0,212,255,0.04)", border: "1px solid rgba(0,212,255,0.15)" }}>
|
||||
<h3 className="text-sm font-semibold text-cyan-400 mb-3">Distribution Workflow</h3>
|
||||
<div className="space-y-2 text-sm text-white/60">
|
||||
|
|
@ -530,6 +850,7 @@ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void })
|
|||
<p>5. <strong className="text-white/80">No EVM address?</strong> Contact buyer via Telegram/email to get their BSC address</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { usePresale } from "@/hooks/usePresale";
|
|||
import { CONTRACTS, PRESALE_CONFIG, formatNumber, shortenAddress } from "@/lib/contracts";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { type Lang, useTranslation } from "@/lib/i18n";
|
||||
import { WalletSelector } from "@/components/WalletSelector";
|
||||
|
||||
// ─── Network Tab Types ────────────────────────────────────────────────────────
|
||||
type NetworkTab = "BSC" | "ETH" | "TRON";
|
||||
|
|
@ -104,18 +105,67 @@ function StepBadge({ num, text }: { num: number; text: string }) {
|
|||
}
|
||||
|
||||
// ─── TRC20 Purchase Panel ─────────────────────────────────────────────────────
|
||||
function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
||||
function TRC20Panel({ usdtAmount, lang, connectedAddress, onConnectWallet }: { usdtAmount: number; lang: Lang; connectedAddress?: string; onConnectWallet?: () => void }) {
|
||||
const { t } = useTranslation(lang);
|
||||
const tokenAmount = usdtAmount / PRESALE_CONFIG.tokenPrice;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [evmAddress, setEvmAddress] = useState("");
|
||||
const [evmAddress, setEvmAddress] = useState(connectedAddress || "");
|
||||
const [evmAddrError, setEvmAddrError] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
// TronLink detection state
|
||||
const [tronAddress, setTronAddress] = useState<string | null>(null);
|
||||
const [isTronConnecting, setIsTronConnecting] = useState(false);
|
||||
const hasTronLink = typeof window !== "undefined" && (!!(window as unknown as Record<string, unknown>).tronWeb || !!(window as unknown as Record<string, unknown>).tronLink);
|
||||
|
||||
// Auto-detect TronLink on mount
|
||||
useEffect(() => {
|
||||
const detectTron = async () => {
|
||||
// Wait briefly for TronLink to inject
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const tronWeb = (window as unknown as Record<string, unknown>).tronWeb as { defaultAddress?: { base58?: string }; ready?: boolean } | undefined;
|
||||
if (tronWeb && tronWeb.ready && tronWeb.defaultAddress?.base58) {
|
||||
setTronAddress(tronWeb.defaultAddress.base58);
|
||||
}
|
||||
};
|
||||
detectTron();
|
||||
}, []);
|
||||
|
||||
// Connect TronLink wallet
|
||||
const handleConnectTronLink = async () => {
|
||||
setIsTronConnecting(true);
|
||||
try {
|
||||
const tronLink = (window as unknown as Record<string, unknown>).tronLink as { request?: (args: { method: string }) => Promise<{ code: number }> } | undefined;
|
||||
const tronWeb = (window as unknown as Record<string, unknown>).tronWeb as { defaultAddress?: { base58?: string }; ready?: boolean } | undefined;
|
||||
if (tronLink?.request) {
|
||||
const result = await tronLink.request({ method: 'tron_requestAccounts' });
|
||||
if (result?.code === 200 && tronWeb?.defaultAddress?.base58) {
|
||||
setTronAddress(tronWeb.defaultAddress.base58);
|
||||
toast.success(lang === "zh" ? "TronLink已连接!" : "TronLink connected!");
|
||||
}
|
||||
} else if (tronWeb?.ready && tronWeb.defaultAddress?.base58) {
|
||||
setTronAddress(tronWeb.defaultAddress.base58);
|
||||
toast.success(lang === "zh" ? "TronLink已检测到!" : "TronLink detected!");
|
||||
} else {
|
||||
toast.error(lang === "zh" ? "请安装TronLink钱包扩展" : "Please install TronLink wallet extension");
|
||||
}
|
||||
} catch {
|
||||
toast.error(lang === "zh" ? "连接TronLink失败" : "Failed to connect TronLink");
|
||||
} finally {
|
||||
setIsTronConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-fill EVM address whenever wallet connects or address changes (unless user already submitted)
|
||||
useEffect(() => {
|
||||
if (connectedAddress && !submitted) {
|
||||
setEvmAddress(connectedAddress);
|
||||
}
|
||||
}, [connectedAddress, submitted]);
|
||||
|
||||
const submitTrc20Mutation = trpc.presale.registerTrc20Intent.useMutation({
|
||||
onSuccess: () => {
|
||||
setSubmitted(true);
|
||||
toast.success(lang === "zh" ? "EVM地址已保存!" : "EVM address saved!");
|
||||
toast.success(lang === "zh" ? "XIC接收地址已保存!" : "XIC receiving address saved!");
|
||||
},
|
||||
onError: (err: { message: string }) => {
|
||||
toast.error(err.message);
|
||||
|
|
@ -130,8 +180,8 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
|||
};
|
||||
|
||||
const validateEvmAddress = (addr: string) => {
|
||||
if (!addr) return lang === "zh" ? "请输入您的EVM地址" : "Please enter your EVM address";
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) return lang === "zh" ? "无效的EVM地址格式(应以0x开头,共42位)" : "Invalid EVM address format (must start with 0x, 42 chars)";
|
||||
if (!addr) return lang === "zh" ? "请输入您的XIC接收地址" : "Please enter your XIC receiving address";
|
||||
if (!/^0x[0-9a-fA-F]{40}$/.test(addr)) return lang === "zh" ? "无效的XIC接收地址格式(应以0x开头,入42位)" : "Invalid XIC receiving address format (must start with 0x, 42 chars)";
|
||||
return "";
|
||||
};
|
||||
|
||||
|
|
@ -149,20 +199,33 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
|||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400 text-sm">⚠️</span>
|
||||
<p className="text-sm font-semibold text-amber-300">
|
||||
{lang === "zh" ? "必填:您的EVM钱包地址(用于接收XIC代币)" : "Required: Your EVM Wallet Address (to receive XIC tokens)"}
|
||||
{lang === "zh" ? "必填:您的XIC接收地址(BSC/ETH钉包地址)" : "Required: Your XIC Receiving Address (BSC/ETH wallet address)"}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-white/50">
|
||||
{lang === "zh"
|
||||
? "XIC代币在BSC网络上发放。请提供您的BSC/ETH地址(0x开头),以便我们将代币发送给您。"
|
||||
: "XIC tokens are distributed on BSC. Please provide your BSC/ETH address (starts with 0x) so we can send your tokens."}
|
||||
? "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."}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{/* WalletSelector — shown when address not yet filled */}
|
||||
{!evmAddress && !submitted && (
|
||||
<WalletSelector
|
||||
lang={lang}
|
||||
connectedAddress={connectedAddress}
|
||||
onAddressDetected={(addr) => {
|
||||
setEvmAddress(addr);
|
||||
setEvmAddrError("");
|
||||
toast.success(lang === "zh" ? "XIC接收地址已自动填充!" : "XIC receiving address auto-filled!");
|
||||
if (onConnectWallet) onConnectWallet();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={evmAddress}
|
||||
onChange={e => { setEvmAddress(e.target.value); setEvmAddrError(""); setSubmitted(false); }}
|
||||
placeholder={lang === "zh" ? "0x... (您的BSC/ETH地址)" : "0x... (your BSC/ETH address)"}
|
||||
placeholder={lang === "zh" ? "0x... (您的XIC接收地址)" : "0x... (your XIC receiving address)"}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm font-mono"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
|
|
@ -172,7 +235,7 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
|||
}}
|
||||
/>
|
||||
{evmAddrError && <p className="text-xs text-red-400">{evmAddrError}</p>}
|
||||
{submitted && <p className="text-xs text-green-400">✓ {lang === "zh" ? "EVM地址已保存" : "EVM address saved"}</p>}
|
||||
{submitted && <p className="text-xs text-green-400">✓ {lang === "zh" ? "XIC接收地址已保存" : "XIC receiving address saved"}</p>}
|
||||
<button
|
||||
onClick={handleEvmSubmit}
|
||||
disabled={submitTrc20Mutation.isPending || submitted || !evmAddress}
|
||||
|
|
@ -184,11 +247,85 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
|||
opacity: !evmAddress ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{submitTrc20Mutation.isPending ? (lang === "zh" ? "保存中..." : "Saving...") : submitted ? (lang === "zh" ? "✓ 已保存" : "✓ Saved") : (lang === "zh" ? "保存EVM地址" : "Save EVM Address")}
|
||||
{submitTrc20Mutation.isPending ? (lang === "zh" ? "保存中..." : "Saving...") : submitted ? (lang === "zh" ? "✓ 已保存" : "✓ Saved") : (lang === "zh" ? "保存XIC接收地址" : "Save XIC Receiving Address")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TronLink Wallet Detection */}
|
||||
<div className="rounded-xl p-4 space-y-3" style={{ background: "rgba(255,0,19,0.06)", border: "1px solid rgba(255,0,19,0.25)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="12" fill="#FF0013"/>
|
||||
<path d="M12 5L19 9.5V14.5L12 19L5 14.5V9.5L12 5Z" fill="white" opacity="0.9"/>
|
||||
<path d="M12 8L16 10.5V13.5L12 16L8 13.5V10.5L12 8Z" fill="#FF0013"/>
|
||||
</svg>
|
||||
<p className="text-sm font-semibold" style={{ color: "#ff6b6b" }}>
|
||||
{lang === "zh" ? "TronLink 钱包(可选)" : "TronLink Wallet (Optional)"}
|
||||
</p>
|
||||
</div>
|
||||
{tronAddress ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-white/50">
|
||||
{lang === "zh" ? "已连接 TronLink 地址:" : "Connected TronLink address:"}
|
||||
</p>
|
||||
<div
|
||||
className="p-3 rounded-lg text-xs font-mono break-all"
|
||||
style={{ background: "rgba(0,230,118,0.08)", border: "1px solid rgba(0,230,118,0.3)", color: "#00e676" }}
|
||||
>
|
||||
✓ {tronAddress}
|
||||
</div>
|
||||
<p className="text-xs text-white/40">
|
||||
{lang === "zh"
|
||||
? "您的 TronLink 已连接。请在上方填写 XIC 接收地址,然后向下方地址发送 USDT。"
|
||||
: "TronLink connected. Please fill your XIC receiving address above, then send USDT to the address below."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-white/50">
|
||||
{lang === "zh"
|
||||
? "如果您使用 TronLink 钱包,可以连接后自动验证您的 TRON 地址。"
|
||||
: "If you use TronLink wallet, connect to auto-verify your TRON address."}
|
||||
</p>
|
||||
{hasTronLink ? (
|
||||
<button
|
||||
onClick={handleConnectTronLink}
|
||||
disabled={isTronConnecting}
|
||||
className="w-full py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all hover:opacity-90"
|
||||
style={{
|
||||
background: "rgba(255,0,19,0.15)",
|
||||
border: "1px solid rgba(255,0,19,0.4)",
|
||||
color: "#ff6b6b",
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
|
||||
<path d="M12 6v6l4 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
{isTronConnecting
|
||||
? (lang === "zh" ? "连接中..." : "Connecting...")
|
||||
: (lang === "zh" ? "连接 TronLink 自动验证" : "Connect TronLink to verify")}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href="https://www.tronlink.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full py-2.5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all hover:opacity-90 block text-center"
|
||||
style={{
|
||||
background: "rgba(255,0,19,0.08)",
|
||||
border: "1px solid rgba(255,0,19,0.25)",
|
||||
color: "rgba(255,107,107,0.7)",
|
||||
}}
|
||||
>
|
||||
{lang === "zh" ? "安装 TronLink 钱包 →" : "Install TronLink Wallet →"}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="nac-card-blue rounded-xl p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">{t("trc20_send_to")}</p>
|
||||
<div
|
||||
|
|
@ -210,8 +347,8 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
|||
<div className="space-y-2">
|
||||
<StepBadge num={1} text={
|
||||
lang === "zh"
|
||||
? `在上方填写您的EVM地址并保存`
|
||||
: `Enter and save your EVM address above`
|
||||
? `在上方填写您的XIC接收地址并保存`
|
||||
: `Enter and save your XIC receiving address above`
|
||||
} />
|
||||
<StepBadge num={2} text={
|
||||
lang === "zh"
|
||||
|
|
@ -234,10 +371,9 @@ function TRC20Panel({ usdtAmount, lang }: { usdtAmount: number; lang: Lang }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ─── EVM Purchase Panel ───────────────────────────────────────────────────────
|
||||
function EVMPurchasePanel({ network, lang }: { network: "BSC" | "ETH"; lang: Lang }) {
|
||||
// ─── EVM Purchase Panel ─────────────────────────────────────────────────────
|
||||
function EVMPurchasePanel({ network, lang, wallet }: { network: "BSC" | "ETH"; lang: Lang; wallet: WalletHookReturn }) {
|
||||
const { t } = useTranslation(lang);
|
||||
const wallet = useWallet();
|
||||
const { purchaseState, buyWithUSDT, reset, calcTokens, getUsdtBalance } = usePresale(wallet, network);
|
||||
const [usdtInput, setUsdtInput] = useState("100");
|
||||
const [usdtBalance, setUsdtBalance] = useState<number | null>(null);
|
||||
|
|
@ -280,18 +416,17 @@ function EVMPurchasePanel({ network, lang }: { network: "BSC" | "ETH"; lang: Lan
|
|||
if (!wallet.isConnected) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-6">
|
||||
<div className="text-4xl mb-3">🔗</div>
|
||||
<p className="text-white/60 text-sm mb-4">{t("buy_connect_msg")}</p>
|
||||
<button
|
||||
onClick={wallet.connect}
|
||||
disabled={wallet.isConnecting}
|
||||
className="btn-primary-nac w-full py-3 rounded-xl text-base font-bold pulse-amber"
|
||||
style={{ fontFamily: "'Space Grotesk', sans-serif" }}
|
||||
>
|
||||
{wallet.isConnecting ? t("nav_connecting") : t("buy_connect_btn")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 text-center">{t("buy_connect_msg")}</p>
|
||||
<WalletSelector
|
||||
lang={lang}
|
||||
connectedAddress={wallet.address ?? undefined}
|
||||
onAddressDetected={(addr) => {
|
||||
// Address detected — wallet is now connected, trigger wallet.connect to sync state
|
||||
wallet.connect();
|
||||
toast.success(lang === "zh" ? `已连接: ${addr.slice(0, 6)}...${addr.slice(-4)}` : `Connected: ${addr.slice(0, 6)}...${addr.slice(-4)}`);
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
<div className="text-xs text-white/40 text-center">{t("buy_connect_hint")}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -675,9 +810,9 @@ function ChatSupport({ lang }: { lang: Lang }) {
|
|||
}
|
||||
|
||||
// ─── Navbar Wallet Button ─────────────────────────────────────────────────────
|
||||
function NavWalletButton({ lang }: { lang: Lang }) {
|
||||
type WalletHookReturn = ReturnType<typeof useWallet>;
|
||||
function NavWalletButton({ lang, wallet }: { lang: Lang; wallet: WalletHookReturn }) {
|
||||
const { t } = useTranslation(lang);
|
||||
const wallet = useWallet();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -796,11 +931,31 @@ export default function Home() {
|
|||
|
||||
const stats = onChainStats || FALLBACK_STATS;
|
||||
const progressPct = stats.progressPct || 0;
|
||||
// Presale active/paused status from backend config
|
||||
const isPresalePaused = (onChainStats as any)?.presaleStatus === "paused";
|
||||
|
||||
// 钱包状态提升到顶层,共享给NavWalletButton和EVMPurchasePanel
|
||||
const wallet = useWallet();
|
||||
|
||||
const networks: NetworkTab[] = ["BSC", "ETH", "TRON"];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: "#0a0a0f" }}>
|
||||
{/* ── Presale Paused Banner ── */}
|
||||
{isPresalePaused && (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 z-[60] flex items-center justify-center gap-3 py-3 px-4 text-sm font-bold"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, rgba(255,60,60,0.95) 0%, rgba(200,0,0,0.95) 100%)",
|
||||
color: "white",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
<span className="text-lg">⏸</span>
|
||||
<span>{lang === "zh" ? "预售活动已暂停,暂时无法购买。请关注官方渠道获取最新公告。" : "Presale is currently paused. Please follow our official channels for updates."}</span>
|
||||
<span className="text-lg">⏸</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ── Navigation ── */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-6 py-4" style={{ background: "rgba(10,10,15,0.9)", borderBottom: "1px solid rgba(240,180,41,0.1)", backdropFilter: "blur(12px)" }}>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -820,7 +975,7 @@ export default function Home() {
|
|||
</span>
|
||||
</Link>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<NavWalletButton lang={lang} />
|
||||
<NavWalletButton lang={lang} wallet={wallet} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -985,9 +1140,28 @@ export default function Home() {
|
|||
</div>
|
||||
|
||||
{/* Purchase Area */}
|
||||
<div>
|
||||
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} />}
|
||||
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} />}
|
||||
<div className="relative">
|
||||
{/* Presale Paused Overlay */}
|
||||
{isPresalePaused && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center rounded-2xl gap-3"
|
||||
style={{
|
||||
background: "rgba(10,10,15,0.88)",
|
||||
border: "1.5px solid rgba(255,60,60,0.4)",
|
||||
backdropFilter: "blur(4px)",
|
||||
}}
|
||||
>
|
||||
<div className="text-4xl">⏸</div>
|
||||
<p className="text-base font-bold" style={{ color: "#ff6060", fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
{lang === "zh" ? "预售已暂停" : "Presale Paused"}
|
||||
</p>
|
||||
<p className="text-xs text-white/50 text-center px-4">
|
||||
{lang === "zh" ? "请关注官方 Telegram / Twitter 获取最新公告" : "Follow our official Telegram / Twitter for updates"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{activeNetwork === "BSC" && <EVMPurchasePanel network="BSC" lang={lang} wallet={wallet} />}
|
||||
{activeNetwork === "ETH" && <EVMPurchasePanel network="ETH" lang={lang} wallet={wallet} />}
|
||||
{activeNetwork === "TRON" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -1015,7 +1189,7 @@ export default function Home() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TRC20Panel usdtAmount={parseFloat(trcUsdtAmount) || 0} lang={lang} />
|
||||
<TRC20Panel usdtAmount={parseFloat(trcUsdtAmount) || 0} lang={lang} connectedAddress={wallet.address || undefined} onConnectWallet={wallet.connect} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE `presale_config` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`key` varchar(64) NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`label` varchar(128),
|
||||
`description` varchar(256),
|
||||
`type` varchar(32) DEFAULT 'text',
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `presale_config_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `presale_config_key_unique` UNIQUE(`key`)
|
||||
);
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "6b25cb51-fd4a-43ff-9411-e1efd553f304",
|
||||
"prevId": "58f17be6-1ea0-44cb-9d74-094dbec51be3",
|
||||
"tables": {
|
||||
"presale_config": {
|
||||
"name": "presale_config",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "varchar(256)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "varchar(32)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'text'"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"presale_config_id": {
|
||||
"name": "presale_config_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"presale_config_key_unique": {
|
||||
"name": "presale_config_key_unique",
|
||||
"columns": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"presale_stats_cache": {
|
||||
"name": "presale_stats_cache",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"chain": {
|
||||
"name": "chain",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"usdtRaised": {
|
||||
"name": "usdtRaised",
|
||||
"type": "decimal(30,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"tokensSold": {
|
||||
"name": "tokensSold",
|
||||
"type": "decimal(30,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"weiRaised": {
|
||||
"name": "weiRaised",
|
||||
"type": "decimal(30,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"lastUpdated": {
|
||||
"name": "lastUpdated",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"presale_stats_cache_id": {
|
||||
"name": "presale_stats_cache_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"trc20_intents": {
|
||||
"name": "trc20_intents",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"tronAddress": {
|
||||
"name": "tronAddress",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"evmAddress": {
|
||||
"name": "evmAddress",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expectedUsdt": {
|
||||
"name": "expectedUsdt",
|
||||
"type": "decimal(20,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"matched": {
|
||||
"name": "matched",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"matchedPurchaseId": {
|
||||
"name": "matchedPurchaseId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"trc20_intents_id": {
|
||||
"name": "trc20_intents_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"trc20_purchases": {
|
||||
"name": "trc20_purchases",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"txHash": {
|
||||
"name": "txHash",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fromAddress": {
|
||||
"name": "fromAddress",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"usdtAmount": {
|
||||
"name": "usdtAmount",
|
||||
"type": "decimal(20,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"xicAmount": {
|
||||
"name": "xicAmount",
|
||||
"type": "decimal(30,6)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"blockNumber": {
|
||||
"name": "blockNumber",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('pending','confirmed','distributed','failed')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"distributedAt": {
|
||||
"name": "distributedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"distributeTxHash": {
|
||||
"name": "distributeTxHash",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"evmAddress": {
|
||||
"name": "evmAddress",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"trc20_purchases_id": {
|
||||
"name": "trc20_purchases_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"trc20_purchases_txHash_unique": {
|
||||
"name": "trc20_purchases_txHash_unique",
|
||||
"columns": [
|
||||
"txHash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"openId": {
|
||||
"name": "openId",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"loginMethod": {
|
||||
"name": "loginMethod",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('user','admin')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_openId_unique": {
|
||||
"name": "users_openId_unique",
|
||||
"columns": [
|
||||
"openId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,13 @@
|
|||
"when": 1772950356383,
|
||||
"tag": "0003_volatile_firestar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1772955197567,
|
||||
"tag": "0004_parallel_unus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -81,4 +81,19 @@ export const trc20Intents = mysqlTable("trc20_intents", {
|
|||
});
|
||||
|
||||
export type Trc20Intent = typeof trc20Intents.$inferSelect;
|
||||
export type InsertTrc20Intent = typeof trc20Intents.$inferInsert;
|
||||
export type InsertTrc20Intent = typeof trc20Intents.$inferInsert;
|
||||
|
||||
// Presale configuration — editable by admin from the admin panel
|
||||
// Each row is a key-value pair (e.g. presaleEndDate, tokenPrice, hardCap, etc.)
|
||||
export const presaleConfig = mysqlTable("presale_config", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
key: varchar("key", { length: 64 }).notNull().unique(),
|
||||
value: text("value").notNull(),
|
||||
label: varchar("label", { length: 128 }), // Human-readable label for admin UI
|
||||
description: varchar("description", { length: 256 }), // Help text
|
||||
type: varchar("type", { length: 32 }).default("text"), // text | number | date | boolean | url
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type PresaleConfig = typeof presaleConfig.$inferSelect;
|
||||
export type InsertPresaleConfig = typeof presaleConfig.$inferInsert;
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* Presale Configuration Database Helpers
|
||||
*
|
||||
* Manages the presale_config table — a key-value store for
|
||||
* admin-editable presale parameters.
|
||||
*
|
||||
* Default values are seeded on first access if not present.
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { presaleConfig } from "../drizzle/schema";
|
||||
|
||||
// ─── Default configuration values ─────────────────────────────────────────────
|
||||
export const DEFAULT_CONFIG: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: string;
|
||||
}> = [
|
||||
{
|
||||
key: "presaleEndDate",
|
||||
value: "2026-06-30T23:59:59Z",
|
||||
label: "预售结束时间 (Presale End Date)",
|
||||
description: "ISO 8601 格式,例如:2026-06-30T23:59:59Z",
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
key: "tokenPrice",
|
||||
value: "0.02",
|
||||
label: "代币价格 (Token Price, USDT)",
|
||||
description: "每枚 XIC 的 USDT 价格",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "hardCap",
|
||||
value: "5000000",
|
||||
label: "硬顶 (Hard Cap, USDT)",
|
||||
description: "预售最大募资额(USDT)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "listingPrice",
|
||||
value: "0.10",
|
||||
label: "上市目标价格 (Target Listing Price, USDT)",
|
||||
description: "预计上市价格(仅展示用)",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "totalSupply",
|
||||
value: "100000000000",
|
||||
label: "总供应量 (Total Supply)",
|
||||
description: "XIC 代币总供应量",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "maxPurchaseUsdt",
|
||||
value: "50000",
|
||||
label: "单笔最大购买额 (Max Purchase, USDT)",
|
||||
description: "单笔购买最大 USDT 金额",
|
||||
type: "number",
|
||||
},
|
||||
{
|
||||
key: "presaleStatus",
|
||||
value: "live",
|
||||
label: "预售状态 (Presale Status)",
|
||||
description: "live = 进行中,paused = 暂停,ended = 已结束",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "heroTitle",
|
||||
value: "XIC Token Presale",
|
||||
label: "首页标题 (Hero Title)",
|
||||
description: "首页大标题文字",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "heroSubtitle",
|
||||
value: "New AssetChain — The next-generation RWA native blockchain with AI-native compliance, CBPP consensus, and Charter smart contracts.",
|
||||
label: "首页副标题 (Hero Subtitle)",
|
||||
description: "首页副标题文字",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "tronReceivingAddress",
|
||||
value: "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp",
|
||||
label: "TRC20 收款地址 (TRON Receiving Address)",
|
||||
description: "接收 TRC20 USDT 的 TRON 地址",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "telegramBotToken",
|
||||
value: "",
|
||||
label: "Telegram Bot Token",
|
||||
description: "从 @BotFather 获取的 Bot Token,用于发送购买通知",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "telegramChatId",
|
||||
value: "",
|
||||
label: "Telegram Chat ID",
|
||||
description: "接收通知的 Chat ID(个人账号或群组)",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
export interface ConfigMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed default config values if not already present.
|
||||
*/
|
||||
export async function seedDefaultConfig(): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
|
||||
for (const item of DEFAULT_CONFIG) {
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(presaleConfig)
|
||||
.where(eq(presaleConfig.key, item.key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(presaleConfig).values({
|
||||
key: item.key,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[Config] Failed to seed ${item.key}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all config values as a key-value map.
|
||||
* Falls back to DEFAULT_CONFIG values if DB is unavailable.
|
||||
*/
|
||||
export async function getAllConfig(): Promise<ConfigMap> {
|
||||
// Build defaults map
|
||||
const defaults: ConfigMap = {};
|
||||
for (const item of DEFAULT_CONFIG) {
|
||||
defaults[item.key] = item.value;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) return defaults;
|
||||
|
||||
try {
|
||||
const rows = await db.select().from(presaleConfig);
|
||||
const result: ConfigMap = { ...defaults };
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value;
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.warn("[Config] Failed to read config:", e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single config value by key.
|
||||
*/
|
||||
export async function getConfig(key: string): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
const def = DEFAULT_CONFIG.find((c) => c.key === key);
|
||||
return def?.value ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(presaleConfig)
|
||||
.where(eq(presaleConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length > 0) return rows[0].value;
|
||||
|
||||
// Fall back to default
|
||||
const def = DEFAULT_CONFIG.find((c) => c.key === key);
|
||||
return def?.value ?? null;
|
||||
} catch (e) {
|
||||
console.warn(`[Config] Failed to read ${key}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single config value.
|
||||
*/
|
||||
export async function setConfig(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("DB unavailable");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(presaleConfig)
|
||||
.where(eq(presaleConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(presaleConfig)
|
||||
.set({ value })
|
||||
.where(eq(presaleConfig.key, key));
|
||||
} else {
|
||||
const def = DEFAULT_CONFIG.find((c) => c.key === key);
|
||||
await db.insert(presaleConfig).values({
|
||||
key,
|
||||
value,
|
||||
label: def?.label ?? key,
|
||||
description: def?.description ?? "",
|
||||
type: def?.type ?? "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2,18 +2,45 @@
|
|||
* On-chain data service
|
||||
* Reads presale stats from BSC and ETH contracts using ethers.js
|
||||
* Caches results in DB to avoid rate limiting
|
||||
*
|
||||
* RPC Strategy: Multi-node failover pool — tries each node in order until one succeeds
|
||||
*/
|
||||
import { ethers } from "ethers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { presaleStatsCache } from "../drizzle/schema";
|
||||
|
||||
// ─── Multi-node RPC Pool ────────────────────────────────────────────────────────
|
||||
// Multiple public RPC endpoints for each chain — tried in order, first success wins
|
||||
const RPC_POOLS = {
|
||||
BSC: [
|
||||
"https://bsc-dataseed1.binance.org/",
|
||||
"https://bsc-dataseed2.binance.org/",
|
||||
"https://bsc-dataseed3.binance.org/",
|
||||
"https://bsc-dataseed4.binance.org/",
|
||||
"https://bsc-dataseed1.defibit.io/",
|
||||
"https://bsc-dataseed2.defibit.io/",
|
||||
"https://bsc.publicnode.com",
|
||||
"https://binance.llamarpc.com",
|
||||
"https://rpc.ankr.com/bsc",
|
||||
],
|
||||
ETH: [
|
||||
"https://eth.llamarpc.com",
|
||||
"https://ethereum.publicnode.com",
|
||||
"https://rpc.ankr.com/eth",
|
||||
"https://1rpc.io/eth",
|
||||
"https://eth.drpc.org",
|
||||
"https://cloudflare-eth.com",
|
||||
"https://rpc.payload.de",
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Contract Addresses ────────────────────────────────────────────────────────
|
||||
export const CONTRACTS = {
|
||||
BSC: {
|
||||
presale: "0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c",
|
||||
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
|
||||
rpc: "https://bsc-dataseed1.binance.org/",
|
||||
rpc: RPC_POOLS.BSC[0],
|
||||
chainId: 56,
|
||||
chainName: "BNB Smart Chain",
|
||||
explorerUrl: "https://bscscan.com",
|
||||
|
|
@ -22,7 +49,7 @@ export const CONTRACTS = {
|
|||
ETH: {
|
||||
presale: "0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3",
|
||||
token: "0x59ff34dd59680a7125782b1f6df2a86ed46f5a24",
|
||||
rpc: "https://eth.llamarpc.com",
|
||||
rpc: RPC_POOLS.ETH[0],
|
||||
chainId: 1,
|
||||
chainName: "Ethereum",
|
||||
explorerUrl: "https://etherscan.io",
|
||||
|
|
@ -56,6 +83,7 @@ export interface PresaleStats {
|
|||
tokensSold: number;
|
||||
lastUpdated: Date;
|
||||
fromCache: boolean;
|
||||
rpcUsed?: string;
|
||||
}
|
||||
|
||||
export interface CombinedStats {
|
||||
|
|
@ -73,45 +101,89 @@ export interface CombinedStats {
|
|||
// Cache TTL: 60 seconds
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
async function fetchChainStats(chain: "BSC" | "ETH"): Promise<{ usdtRaised: number; tokensSold: number }> {
|
||||
const cfg = CONTRACTS[chain];
|
||||
const provider = new ethers.JsonRpcProvider(cfg.rpc);
|
||||
const contract = new ethers.Contract(cfg.presale, PRESALE_ABI, provider);
|
||||
// RPC timeout: 8 seconds per node
|
||||
const RPC_TIMEOUT_MS = 8_000;
|
||||
|
||||
let usdtRaised = 0;
|
||||
let tokensSold = 0;
|
||||
/**
|
||||
* Try each RPC node in the pool until one succeeds.
|
||||
* Returns { usdtRaised, tokensSold, rpcUsed } or throws if all fail.
|
||||
*/
|
||||
async function fetchChainStatsWithFailover(
|
||||
chain: "BSC" | "ETH"
|
||||
): Promise<{ usdtRaised: number; tokensSold: number; rpcUsed: string }> {
|
||||
const pool = RPC_POOLS[chain];
|
||||
const presaleAddress = CONTRACTS[chain].presale;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Try different function names that might exist in the contract
|
||||
try {
|
||||
const raw = await contract.totalUSDTRaised();
|
||||
usdtRaised = Number(ethers.formatUnits(raw, 6)); // USDT has 6 decimals
|
||||
} catch {
|
||||
for (const rpcUrl of pool) {
|
||||
try {
|
||||
const raw = await contract.usdtRaised();
|
||||
usdtRaised = Number(ethers.formatUnits(raw, 6));
|
||||
} catch {
|
||||
try {
|
||||
const raw = await contract.weiRaised();
|
||||
usdtRaised = Number(ethers.formatUnits(raw, 6));
|
||||
} catch {
|
||||
console.warn(`[OnChain] Could not read usdtRaised from ${chain}`);
|
||||
}
|
||||
const provider = new ethers.JsonRpcProvider(rpcUrl, undefined, {
|
||||
staticNetwork: true,
|
||||
polling: false,
|
||||
});
|
||||
|
||||
// Set a timeout for the provider
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`RPC timeout: ${rpcUrl}`)), RPC_TIMEOUT_MS)
|
||||
);
|
||||
|
||||
const contract = new ethers.Contract(presaleAddress, PRESALE_ABI, provider);
|
||||
|
||||
let usdtRaised = 0;
|
||||
let tokensSold = 0;
|
||||
|
||||
// Try different function names that might exist in the contract
|
||||
const usdtPromise = (async () => {
|
||||
try {
|
||||
const raw = await contract.totalUSDTRaised();
|
||||
return Number(ethers.formatUnits(raw, 6));
|
||||
} catch {
|
||||
try {
|
||||
const raw = await contract.usdtRaised();
|
||||
return Number(ethers.formatUnits(raw, 6));
|
||||
} catch {
|
||||
try {
|
||||
const raw = await contract.weiRaised();
|
||||
return Number(ethers.formatUnits(raw, 6));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const tokensPromise = (async () => {
|
||||
try {
|
||||
const raw = await contract.totalTokensSold();
|
||||
return Number(ethers.formatUnits(raw, 18));
|
||||
} catch {
|
||||
try {
|
||||
const raw = await contract.tokensSold();
|
||||
return Number(ethers.formatUnits(raw, 18));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const [usdtResult, tokensResult] = await Promise.race([
|
||||
Promise.all([usdtPromise, tokensPromise]),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
usdtRaised = usdtResult;
|
||||
tokensSold = tokensResult;
|
||||
|
||||
console.log(`[OnChain] ${chain} stats fetched via ${rpcUrl}: $${usdtRaised} USDT, ${tokensSold} XIC`);
|
||||
return { usdtRaised, tokensSold, rpcUsed: rpcUrl };
|
||||
} catch (e) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
errors.push(`${rpcUrl}: ${errMsg}`);
|
||||
console.warn(`[OnChain] ${chain} RPC failed (${rpcUrl}): ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await contract.totalTokensSold();
|
||||
tokensSold = Number(ethers.formatUnits(raw, 18)); // XIC has 18 decimals
|
||||
} catch {
|
||||
try {
|
||||
const raw = await contract.tokensSold();
|
||||
tokensSold = Number(ethers.formatUnits(raw, 18));
|
||||
} catch {
|
||||
console.warn(`[OnChain] Could not read tokensSold from ${chain}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { usdtRaised, tokensSold };
|
||||
throw new Error(`All ${chain} RPC nodes failed:\n${errors.join("\n")}`);
|
||||
}
|
||||
|
||||
export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStats> {
|
||||
|
|
@ -144,15 +216,18 @@ export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStat
|
|||
}
|
||||
}
|
||||
|
||||
// Fetch fresh from chain
|
||||
// Fetch fresh from chain with failover
|
||||
let usdtRaised = 0;
|
||||
let tokensSold = 0;
|
||||
let rpcUsed = "";
|
||||
|
||||
try {
|
||||
const data = await fetchChainStats(chain);
|
||||
const data = await fetchChainStatsWithFailover(chain);
|
||||
usdtRaised = data.usdtRaised;
|
||||
tokensSold = data.tokensSold;
|
||||
rpcUsed = data.rpcUsed;
|
||||
} catch (e) {
|
||||
console.error(`[OnChain] Failed to fetch ${chain} stats:`, e);
|
||||
console.error(`[OnChain] All ${chain} RPC nodes exhausted:`, e);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
|
|
@ -192,6 +267,7 @@ export async function getPresaleStats(chain: "BSC" | "ETH"): Promise<PresaleStat
|
|||
tokensSold,
|
||||
lastUpdated: new Date(),
|
||||
fromCache: false,
|
||||
rpcUsed,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { trc20Purchases, trc20Intents } from "../drizzle/schema";
|
|||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { notifyDistributed, testTelegramConnection } from "./telegram";
|
||||
import { getAllConfig, getConfig, setConfig, seedDefaultConfig, DEFAULT_CONFIG } from "./configDb";
|
||||
|
||||
// Admin password from env (fallback for development)
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "NACadmin2026!";
|
||||
|
|
@ -28,7 +30,10 @@ export const appRouter = router({
|
|||
presale: router({
|
||||
// Combined stats from BSC + ETH + TRC20
|
||||
stats: publicProcedure.query(async () => {
|
||||
return await getCombinedStats();
|
||||
const stats = await getCombinedStats();
|
||||
// Append presale active/paused status from config
|
||||
const presaleStatus = await getConfig("presaleStatus");
|
||||
return { ...stats, presaleStatus: presaleStatus ?? "live" };
|
||||
}),
|
||||
|
||||
// Single chain stats
|
||||
|
|
@ -190,6 +195,12 @@ export const appRouter = router({
|
|||
const db = await getDb();
|
||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
|
||||
|
||||
const purchase = await db
|
||||
.select()
|
||||
.from(trc20Purchases)
|
||||
.where(eq(trc20Purchases.id, input.purchaseId))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.update(trc20Purchases)
|
||||
.set({
|
||||
|
|
@ -200,6 +211,20 @@ export const appRouter = router({
|
|||
})
|
||||
.where(eq(trc20Purchases.id, input.purchaseId));
|
||||
|
||||
// Send Telegram notification
|
||||
if (purchase[0]?.evmAddress) {
|
||||
try {
|
||||
await notifyDistributed({
|
||||
txHash: purchase[0].txHash,
|
||||
evmAddress: purchase[0].evmAddress,
|
||||
xicAmount: Number(purchase[0].xicAmount),
|
||||
distributeTxHash: input.distributeTxHash,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[Admin] Telegram notification failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
|
|
@ -264,6 +289,75 @@ export const appRouter = router({
|
|||
totalXic: Number(r.totalXic || 0),
|
||||
}));
|
||||
}),
|
||||
|
||||
// ─── Presale Configuration Management ─────────────────────────────────
|
||||
// Get all config key-value pairs with metadata
|
||||
getConfig: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
if (!input.token.startsWith("bmFjLWFkbWlu")) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
|
||||
}
|
||||
// Seed defaults first
|
||||
await seedDefaultConfig();
|
||||
const db = await getDb();
|
||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB unavailable" });
|
||||
const { presaleConfig } = await import("../drizzle/schema");
|
||||
const rows = await db.select().from(presaleConfig);
|
||||
// Merge with DEFAULT_CONFIG metadata
|
||||
return DEFAULT_CONFIG.map(def => {
|
||||
const row = rows.find(r => r.key === def.key);
|
||||
return {
|
||||
key: def.key,
|
||||
value: row?.value ?? def.value,
|
||||
label: def.label,
|
||||
description: def.description,
|
||||
type: def.type,
|
||||
updatedAt: row?.updatedAt ?? null,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
// Update a single config value
|
||||
setConfig: publicProcedure
|
||||
.input(z.object({
|
||||
token: z.string(),
|
||||
key: z.string().min(1).max(64),
|
||||
value: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
if (!input.token.startsWith("bmFjLWFkbWlu")) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
|
||||
}
|
||||
// Validate key is in allowed list
|
||||
const allowed = DEFAULT_CONFIG.map(c => c.key);
|
||||
if (!allowed.includes(input.key)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown config key: ${input.key}` });
|
||||
}
|
||||
await setConfig(input.key, input.value);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Test Telegram connection
|
||||
testTelegram: publicProcedure
|
||||
.input(z.object({
|
||||
token: z.string(),
|
||||
botToken: z.string().min(10),
|
||||
chatId: z.string().min(1),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
if (!input.token.startsWith("bmFjLWFkbWlu")) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid admin token" });
|
||||
}
|
||||
const result = await testTelegramConnection(input.botToken, input.chatId);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: result.error || "Test failed" });
|
||||
}
|
||||
// Save to config if test succeeds
|
||||
await setConfig("telegramBotToken", input.botToken);
|
||||
await setConfig("telegramChatId", input.chatId);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Telegram Notification Service
|
||||
*
|
||||
* Sends alerts to admin via Telegram Bot when:
|
||||
* - New TRC20 purchase is confirmed
|
||||
* - Purchase is marked as distributed
|
||||
*
|
||||
* Configuration (via environment variables or admin settings table):
|
||||
* TELEGRAM_BOT_TOKEN — Bot token from @BotFather (e.g. 123456:ABC-DEF...)
|
||||
* TELEGRAM_CHAT_ID — Chat ID to send messages to (personal or group)
|
||||
*
|
||||
* How to set up:
|
||||
* 1. Open Telegram, search @BotFather, send /newbot
|
||||
* 2. Follow prompts to get your Bot Token
|
||||
* 3. Start a chat with your bot (or add it to a group)
|
||||
* 4. Get Chat ID: https://api.telegram.org/bot<TOKEN>/getUpdates
|
||||
* 5. Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in environment variables
|
||||
*/
|
||||
|
||||
const TELEGRAM_API = "https://api.telegram.org";
|
||||
|
||||
export interface TelegramConfig {
|
||||
botToken: string;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Telegram config from environment variables or DB settings.
|
||||
* Returns null if not configured.
|
||||
*/
|
||||
export async function getTelegramConfig(): Promise<TelegramConfig | null> {
|
||||
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = process.env.TELEGRAM_CHAT_ID;
|
||||
|
||||
if (!botToken || !chatId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { botToken, chatId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message via Telegram Bot API.
|
||||
* Uses HTML parse mode for formatting.
|
||||
*/
|
||||
export async function sendTelegramMessage(
|
||||
config: TelegramConfig,
|
||||
message: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const url = `${TELEGRAM_API}/bot${config.botToken}/sendMessage`;
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: config.chatId,
|
||||
text: message,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.text();
|
||||
console.warn(`[Telegram] Send failed (${resp.status}): ${err}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("[Telegram] Message sent successfully");
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[Telegram] Send error:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify admin about a new TRC20 purchase.
|
||||
*/
|
||||
export async function notifyNewTRC20Purchase(purchase: {
|
||||
txHash: string;
|
||||
fromAddress: string;
|
||||
usdtAmount: number;
|
||||
xicAmount: number;
|
||||
evmAddress: string | null;
|
||||
}): Promise<void> {
|
||||
const config = await getTelegramConfig();
|
||||
if (!config) {
|
||||
console.log("[Telegram] Not configured — skipping notification (set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)");
|
||||
return;
|
||||
}
|
||||
|
||||
const evmLine = purchase.evmAddress
|
||||
? `\n🔑 <b>EVM Address:</b> <code>${purchase.evmAddress}</code>`
|
||||
: "\n⚠️ <b>EVM Address:</b> Not provided (manual distribution required)";
|
||||
|
||||
const txUrl = `https://tronscan.org/#/transaction/${purchase.txHash}`;
|
||||
|
||||
const message = [
|
||||
"🟢 <b>New XIC Presale Purchase!</b>",
|
||||
"",
|
||||
`💰 <b>Amount:</b> $${purchase.usdtAmount.toFixed(2)} USDT`,
|
||||
`🪙 <b>XIC Tokens:</b> ${purchase.xicAmount.toLocaleString()} XIC`,
|
||||
`📍 <b>From (TRON):</b> <code>${purchase.fromAddress}</code>`,
|
||||
evmLine,
|
||||
`🔗 <b>TX:</b> <a href="${txUrl}">${purchase.txHash.slice(0, 16)}...</a>`,
|
||||
"",
|
||||
`⏰ ${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
|
||||
].join("\n");
|
||||
|
||||
await sendTelegramMessage(config, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify admin when a purchase is marked as distributed.
|
||||
*/
|
||||
export async function notifyDistributed(purchase: {
|
||||
txHash: string;
|
||||
evmAddress: string;
|
||||
xicAmount: number;
|
||||
distributeTxHash?: string | null;
|
||||
}): Promise<void> {
|
||||
const config = await getTelegramConfig();
|
||||
if (!config) return;
|
||||
|
||||
const distTxLine = purchase.distributeTxHash
|
||||
? `\n🔗 <b>Dist TX:</b> <code>${purchase.distributeTxHash}</code>`
|
||||
: "";
|
||||
|
||||
const message = [
|
||||
"✅ <b>XIC Tokens Distributed!</b>",
|
||||
"",
|
||||
`🪙 <b>XIC Tokens:</b> ${purchase.xicAmount.toLocaleString()} XIC`,
|
||||
`📬 <b>To EVM:</b> <code>${purchase.evmAddress}</code>`,
|
||||
distTxLine,
|
||||
`🔗 <b>Original TX:</b> <code>${purchase.txHash.slice(0, 16)}...</code>`,
|
||||
"",
|
||||
`⏰ ${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
|
||||
].join("\n");
|
||||
|
||||
await sendTelegramMessage(config, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the Telegram connection with a test message.
|
||||
* Returns { success, error } for admin UI feedback.
|
||||
*/
|
||||
export async function testTelegramConnection(
|
||||
botToken: string,
|
||||
chatId: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const config: TelegramConfig = { botToken, chatId };
|
||||
const message = [
|
||||
"🔔 <b>NAC Presale — Telegram Notification Test</b>",
|
||||
"",
|
||||
"✅ Connection successful! You will receive purchase alerts here.",
|
||||
"",
|
||||
`⏰ ${new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} (CST)`,
|
||||
].join("\n");
|
||||
|
||||
const ok = await sendTelegramMessage(config, message);
|
||||
if (!ok) return { success: false, error: "Failed to send message. Check Bot Token and Chat ID." };
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { eq, sql } from "drizzle-orm";
|
|||
import { getDb } from "./db";
|
||||
import { trc20Purchases, trc20Intents } from "../drizzle/schema";
|
||||
import { TOKEN_PRICE_USDT } from "./onchain";
|
||||
import { notifyNewTRC20Purchase } from "./telegram";
|
||||
|
||||
const TRON_RECEIVING_ADDRESS = "TWc2ugYBFN5aSoimAh4qGt9oMyket6NYZp";
|
||||
const TRON_USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
|
||||
|
|
@ -116,6 +117,19 @@ async function processTransaction(tx: TronTransaction): Promise<void> {
|
|||
.where(eq(trc20Intents.id, matchedIntentId));
|
||||
}
|
||||
|
||||
// Send Telegram notification to admin
|
||||
try {
|
||||
await notifyNewTRC20Purchase({
|
||||
txHash: tx.transaction_id,
|
||||
fromAddress: tx.from,
|
||||
usdtAmount,
|
||||
xicAmount,
|
||||
evmAddress: matchedEvmAddress,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[TRC20Monitor] Telegram notification failed:", e);
|
||||
}
|
||||
|
||||
// Attempt auto-distribution via BSC
|
||||
await attemptAutoDistribute(tx.transaction_id, tx.from, xicAmount, matchedEvmAddress);
|
||||
}
|
||||
|
|
|
|||
41
todo.md
41
todo.md
|
|
@ -29,4 +29,45 @@
|
|||
- [x] 浏览器测试验证完整购买流程(BSC/ETH/TRON三网络)
|
||||
- [x] 测试管理员后台(TRC20购买记录、EVM地址意图、分发工作流)
|
||||
- [x] 测试教程页面(多钱包、多网络、中英文切换)
|
||||
- [x] 部署到备份服务器并同步代码库(https://git.newassetchain.io/nacadmin/xic-presale)
|
||||
|
||||
## v5 功能升级
|
||||
- [x] 配置专用高可用RPC节点池(BSC + ETH多节点故障转移)
|
||||
- [x] 添加TRC20购买Telegram通知(新购买确认时自动推送)
|
||||
- [x] 管理员后台添加内容编辑功能(预售参数动态配置)
|
||||
- [ ] 完整域名浏览器购买测试(pre-sale.newassetchain.io)
|
||||
- [ ] 部署到备份服务器并同步代码库
|
||||
|
||||
## v5 备份服务器部署
|
||||
- [x] 修复TRON面板EVM地址自动识别(已连接钱包地址预填入)
|
||||
- [ ] 构建生产版本(移除Manus内联)
|
||||
- [ ] 打包并上传到备份服务器 103.96.148.7
|
||||
- [ ] 备份服务器环境配置(Node.js 22、PM2、MySQL、Nginx)
|
||||
- [ ] 配置环境变量(DATABASE_URL、JWT_SECRET等)
|
||||
- [ ] 启动服务并验证运行状态
|
||||
- [ ] 同步代码到Gitea库(nacadmin/xic-presale)
|
||||
- [ ] 记录部署日志
|
||||
|
||||
## v5 钱包连接修复
|
||||
- [x] 将useWallet()提升到Home顶层,通过props传递给NavWalletButton和EVMPurchasePanel
|
||||
- [x] 验证导航栏和购买面板钱包状态同步
|
||||
- [ ] 完整域名浏览器购买测试验证
|
||||
|
||||
## v6 合约地址更新 + TronLink 检测
|
||||
- [x] 更新 BSC 预售合约地址为 0xc65e7a2738ed884db8d26a6eb2fecf7daca2e90c
|
||||
- [x] 更新 ETH 预售合约地址为 0x85AB2F2d9f7ca7ecB272b5E8726c70f3fd45D1E3
|
||||
- [x] 更新 XIC 代币合约地址为 0x59ff34dd59680a7125782b1f6df2a86ed46f5a24
|
||||
- [x] 为 TRON 标签添加 TronLink 钱包检测并自动填充 TRON 接收地址
|
||||
- [x] 构建并部署到备份服务器 pre-sale.newassetchain.io
|
||||
- [x] 后台 Site Settings 添加“一键开启/关闭预售活动”功能(数据库字段 + 后端 API + 前端开关 UI + 首页状态联动)
|
||||
|
||||
## v7 钉包连接全面修复
|
||||
- [x] 全面修复所有 EVM 钉包(MetaMask、Trust Wallet、OKX、Coinbase等)无法自动填写 EVM 地址的问题
|
||||
- [x] 重写 useWallet hook 支持所有主流 EVM 钉包自动识别
|
||||
- [x] 将页面所有“EVM 地址”文案改为“XIC 接收地址”(中文)/ "XIC Receiving Address"(英文)
|
||||
- [x] 构建并部署到备份服务器并验证
|
||||
|
||||
## v7 钱包列表选择器
|
||||
- [x] 创建 WalletSelector 组件(MetaMask、Trust Wallet、OKX、Coinbase、TokenPocket 检测+连接+安装引导)
|
||||
- [x] 集成 WalletSelector 到 TRON 标签 XIC 接收地址区域- [x] 集成 WalletSelector 到 BSC/ETH 购买面板替换原 Connect Wallet 按鈕钮
|
||||
- [x] 构建并部署到备份服务器
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ function vitePluginManusDebugCollector(): Plugin {
|
|||
};
|
||||
}
|
||||
|
||||
const plugins = [react(), tailwindcss(), jsxLocPlugin(), vitePluginManusRuntime(), vitePluginManusDebugCollector()];
|
||||
const plugins = [react(), tailwindcss(), jsxLocPlugin()];
|
||||
|
||||
export default defineConfig({
|
||||
plugins,
|
||||
|
|
|
|||
Loading…
Reference in New Issue