nac-presale/client/src/pages/Admin.tsx

1116 lines
54 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

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

/**
* Admin Dashboard
* Login-protected page for managing TRC20 purchases
* Features: purchase list, status updates, export, stats
*/
import { useState, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import { Link } from "wouter";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Purchase {
id: number;
txHash: string;
fromAddress: string;
evmAddress: string | null;
usdtAmount: number;
xicAmount: number;
status: "pending" | "confirmed" | "distributed" | "failed";
distributedAt: Date | null;
distributeTxHash: string | null;
createdAt: Date;
}
// ─── Status Badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: Purchase["status"] }) {
const config = {
pending: { color: "#f0b429", bg: "rgba(240,180,41,0.15)", label: "Pending" },
confirmed: { color: "#00d4ff", bg: "rgba(0,212,255,0.15)", label: "Confirmed" },
distributed: { color: "#00e676", bg: "rgba(0,230,118,0.15)", label: "Distributed" },
failed: { color: "#ff5252", bg: "rgba(255,82,82,0.15)", label: "Failed" },
};
const cfg = config[status] || config.pending;
return (
<span
className="text-xs font-semibold px-2 py-1 rounded-full"
style={{ color: cfg.color, background: cfg.bg }}
>
{cfg.label}
</span>
);
}
// ─── Login Form ───────────────────────────────────────────────────────────────
function LoginForm({ onLogin }: { onLogin: (token: string) => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const loginMutation = trpc.admin.login.useMutation({
onSuccess: (data) => {
onLogin(data.token);
},
onError: (err) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError("");
loginMutation.mutate({ password });
};
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: "#0a0a0f" }}>
<div className="w-full max-w-sm px-4">
<div className="rounded-2xl p-8" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(240,180,41,0.2)" }}>
<div className="text-center mb-8">
<div className="text-4xl mb-3">🔐</div>
<h1 className="text-2xl font-bold text-white" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
Admin Dashboard
</h1>
<p className="text-sm text-white/40 mt-1">NAC XIC Presale Management</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm text-white/60 font-medium block mb-2">Admin Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Enter admin password"
className="w-full px-4 py-3 rounded-xl text-sm"
style={{
background: "rgba(255,255,255,0.05)",
border: error ? "1px solid rgba(255,82,82,0.5)" : "1px solid rgba(255,255,255,0.1)",
color: "white",
outline: "none",
}}
autoFocus
/>
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
</div>
<button
type="submit"
disabled={loginMutation.isPending || !password}
className="w-full py-3 rounded-xl text-sm font-bold transition-all"
style={{
background: "linear-gradient(135deg, #f0b429 0%, #ffd700 100%)",
color: "#0a0a0f",
opacity: loginMutation.isPending || !password ? 0.6 : 1,
}}
>
{loginMutation.isPending ? "Logging in..." : "Login"}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/">
<span className="text-xs text-white/30 hover:text-white/60 cursor-pointer transition-colors">
Back to Presale
</span>
</Link>
</div>
</div>
</div>
</div>
);
}
// ─── 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&lt;TOKEN&gt;/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" | "bridge" | "settings">("purchases");
const [bridgePage, setBridgePage] = useState(1);
const [bridgeStatusFilter, setBridgeStatusFilter] = useState<"all" | "pending" | "confirmed" | "distributed" | "failed">("all");
const [bridgeDistribTxInput, setBridgeDistribTxInput] = useState<Record<number, string>>({});
const [updatingBridgeId, setUpdatingBridgeId] = useState<number | null>(null);
const { data: statsData, refetch: refetchStats } = trpc.admin.stats.useQuery({ token });
const { data: intentsData, isLoading: intentsLoading } = trpc.admin.listIntents.useQuery({ token, showAll: false });
const { data: purchasesData, refetch: refetchPurchases, isLoading } = trpc.admin.listPurchases.useQuery({
token,
page,
limit: 20,
status: statusFilter,
});
const { data: bridgeData, refetch: refetchBridge, isLoading: bridgeLoading } = trpc.admin.listBridgeOrders.useQuery({
token,
page: bridgePage,
limit: 20,
status: bridgeStatusFilter,
});
const updateBridgeOrderMutation = trpc.admin.updateBridgeOrder.useMutation({
onSuccess: () => {
refetchBridge();
setUpdatingBridgeId(null);
},
});
const handleUpdateBridgeOrder = (id: number, status: "pending" | "confirmed" | "distributed" | "failed") => {
setUpdatingBridgeId(id);
updateBridgeOrderMutation.mutate({
token,
orderId: id,
status,
distributeTxHash: bridgeDistribTxInput[id] || undefined,
});
};
const markDistributedMutation = trpc.admin.markDistributed.useMutation({
onSuccess: () => {
refetchPurchases();
refetchStats();
setMarkingId(null);
},
});
const handleMarkDistributed = (id: number) => {
setMarkingId(id);
markDistributedMutation.mutate({
token,
purchaseId: id,
distributeTxHash: distributeTxInput[id] || undefined,
});
};
const formatAddress = (addr: string | null) => {
if (!addr) return <span className="text-white/30 text-xs"></span>;
return (
<span className="text-xs font-mono" style={{ color: "#00d4ff" }}>
{addr.slice(0, 8)}...{addr.slice(-6)}
</span>
);
};
const formatDate = (d: Date | null) => {
if (!d) return "—";
return new Date(d).toLocaleString();
};
const totalStats = statsData?.reduce(
(acc, s) => ({
totalUsdt: acc.totalUsdt + s.totalUsdt,
totalXic: acc.totalXic + s.totalXic,
totalCount: acc.totalCount + s.count,
}),
{ totalUsdt: 0, totalXic: 0, totalCount: 0 }
) || { totalUsdt: 0, totalXic: 0, totalCount: 0 };
const pendingCount = statsData?.find(s => s.status === "confirmed")?.count || 0;
// Export CSV
const handleExport = () => {
if (!purchasesData?.purchases) return;
const rows = [
["ID", "TX Hash", "From Address", "EVM Address", "USDT", "XIC", "Status", "Created At", "Distributed At"],
...purchasesData.purchases.map(p => [
p.id,
p.txHash,
p.fromAddress,
p.evmAddress || "",
p.usdtAmount,
p.xicAmount,
p.status,
formatDate(p.createdAt),
formatDate(p.distributedAt),
]),
];
const csv = rows.map(r => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `xic-purchases-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="min-h-screen" style={{ background: "#0a0a0f" }}>
{/* ── Header ── */}
<nav className="sticky top-0 z-50 flex items-center justify-between px-6 py-4"
style={{ background: "rgba(10,10,15,0.95)", borderBottom: "1px solid rgba(240,180,41,0.1)", backdropFilter: "blur(12px)" }}>
<div className="flex items-center gap-3">
<span className="text-xl"></span>
<div>
<span className="font-bold text-white" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>Admin Dashboard</span>
<span className="ml-2 text-xs px-2 py-0.5 rounded-full font-semibold"
style={{ background: "rgba(240,180,41,0.15)", color: "#f0b429", border: "1px solid rgba(240,180,41,0.3)" }}>
NAC XIC Presale
</span>
</div>
</div>
<div className="flex items-center gap-3">
<Link href="/">
<span className="text-sm text-white/50 hover:text-white/80 cursor-pointer transition-colors"> Presale</span>
</Link>
<button
onClick={onLogout}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{ background: "rgba(255,82,82,0.1)", border: "1px solid rgba(255,82,82,0.3)", color: "#ff5252" }}
>
Logout
</button>
</div>
</nav>
<div className="container mx-auto px-4 py-6 max-w-7xl">
{/* ── Stats Cards ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{[
{ label: "Total USDT Raised", value: `$${totalStats.totalUsdt.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, color: "#f0b429" },
{ label: "Total XIC Sold", value: `${(totalStats.totalXic / 1e6).toFixed(2)}M`, color: "#00d4ff" },
{ label: "Total Purchases", value: totalStats.totalCount.toString(), color: "#00e676" },
{ label: "Pending Distribution", value: pendingCount.toString(), color: pendingCount > 0 ? "#ff5252" : "#00e676" },
].map(({ label, value, color }) => (
<div key={label} className="rounded-2xl p-5" style={{ background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.08)" }}>
<div className="text-2xl font-bold" style={{ color, fontFamily: "'Space Grotesk', sans-serif" }}>{value}</div>
<div className="text-xs text-white/40 mt-1">{label}</div>
</div>
))}
</div>
{/* ── Status Breakdown ── */}
{statsData && statsData.length > 0 && (
<div className="rounded-2xl p-5 mb-6" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="text-xs font-semibold uppercase tracking-widest text-white/40 mb-4">Status Breakdown</h3>
<div className="flex flex-wrap gap-4">
{statsData.map(s => (
<div key={s.status} className="flex items-center gap-2">
<StatusBadge status={s.status as Purchase["status"]} />
<span className="text-sm text-white/60">{s.count} purchases · ${s.totalUsdt.toFixed(2)} USDT</span>
</div>
))}
</div>
</div>
)}
{/* ── Tab Navigation ── */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setActiveTab("purchases")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: activeTab === "purchases" ? "rgba(240,180,41,0.15)" : "rgba(255,255,255,0.04)",
border: activeTab === "purchases" ? "1px solid rgba(240,180,41,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: activeTab === "purchases" ? "#f0b429" : "rgba(255,255,255,0.5)",
}}
>
TRC20 Purchases
</button>
<button
onClick={() => setActiveTab("intents")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: activeTab === "intents" ? "rgba(0,212,255,0.15)" : "rgba(255,255,255,0.04)",
border: activeTab === "intents" ? "1px solid rgba(0,212,255,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: activeTab === "intents" ? "#00d4ff" : "rgba(255,255,255,0.5)",
}}
>
EVM Address Intents
{intentsData && intentsData.length > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs" style={{ background: "rgba(0,212,255,0.2)", color: "#00d4ff" }}>
{intentsData.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab("bridge")}
className="px-4 py-2 rounded-xl text-sm font-semibold transition-all"
style={{
background: activeTab === "bridge" ? "rgba(138,71,229,0.15)" : "rgba(255,255,255,0.04)",
border: activeTab === "bridge" ? "1px solid rgba(138,71,229,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: activeTab === "bridge" ? "#a855f7" : "rgba(255,255,255,0.5)",
}}
>
Bridge Orders
{bridgeData && bridgeData.total > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs" style={{ background: "rgba(138,71,229,0.2)", color: "#a855f7" }}>
{bridgeData.total}
</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 ── */}
{activeTab === "intents" && (
<div className="rounded-2xl overflow-hidden mb-6" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
<div className="px-5 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="font-semibold text-white/80" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
Pending EVM Address Intents
<span className="text-white/40 text-sm ml-2">(users who submitted EVM address but payment not yet detected)</span>
</h3>
</div>
<div className="overflow-x-auto">
{intentsLoading ? (
<div className="text-center py-12 text-white/40">Loading...</div>
) : !intentsData?.length ? (
<div className="text-center py-12 text-white/40">No pending intents</div>
) : (
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{["ID", "EVM Address", "Expected USDT", "Matched", "Created"].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">{h}</th>
))}
</tr>
</thead>
<tbody>
{intentsData.map((intent, i) => (
<tr
key={intent.id}
style={{
borderBottom: "1px solid rgba(255,255,255,0.04)",
background: i % 2 === 0 ? "transparent" : "rgba(255,255,255,0.01)",
}}
>
<td className="px-4 py-3 text-white/60">{intent.id}</td>
<td className="px-4 py-3">
<span className="text-xs font-mono" style={{ color: "#00d4ff" }}>
{intent.evmAddress.slice(0, 10)}...{intent.evmAddress.slice(-8)}
</span>
</td>
<td className="px-4 py-3 text-white/60">
{intent.expectedUsdt ? `$${intent.expectedUsdt.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-1 rounded-full" style={{
background: intent.matched ? "rgba(0,230,118,0.15)" : "rgba(240,180,41,0.15)",
color: intent.matched ? "#00e676" : "#f0b429",
}}>
{intent.matched ? "Matched" : "Pending"}
</span>
</td>
<td className="px-4 py-3 text-white/40 text-xs">
{new Date(intent.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
{/* ── Purchases Table ── */}
{activeTab === "purchases" && (
<div className="rounded-2xl overflow-hidden" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
{/* Table Header */}
<div className="flex items-center justify-between px-5 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="font-semibold text-white/80" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
TRC20 Purchases
{purchasesData && <span className="text-white/40 text-sm ml-2">({purchasesData.total} total)</span>}
</h3>
<div className="flex items-center gap-3">
{/* Status Filter */}
<select
value={statusFilter}
onChange={e => { setStatusFilter(e.target.value as typeof statusFilter); setPage(1); }}
className="px-3 py-1.5 rounded-lg text-sm"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", color: "white" }}
>
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="distributed">Distributed</option>
<option value="failed">Failed</option>
</select>
{/* Export */}
<button
onClick={handleExport}
className="px-4 py-1.5 rounded-lg text-sm font-semibold transition-all"
style={{ background: "rgba(0,212,255,0.1)", border: "1px solid rgba(0,212,255,0.3)", color: "#00d4ff" }}
>
Export CSV
</button>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
{isLoading ? (
<div className="text-center py-12 text-white/40">Loading...</div>
) : !purchasesData?.purchases?.length ? (
<div className="text-center py-12 text-white/40">No purchases found</div>
) : (
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{["ID", "TX Hash", "From (TRON)", "EVM Address", "USDT", "XIC", "Status", "Created", "Action"].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">{h}</th>
))}
</tr>
</thead>
<tbody>
{purchasesData.purchases.map((p, i) => (
<tr
key={p.id}
style={{
borderBottom: "1px solid rgba(255,255,255,0.04)",
background: i % 2 === 0 ? "transparent" : "rgba(255,255,255,0.01)",
}}
>
<td className="px-4 py-3 text-white/60">{p.id}</td>
<td className="px-4 py-3">
<a
href={`https://tronscan.org/#/transaction/${p.txHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono hover:underline"
style={{ color: "#00d4ff" }}
>
{p.txHash.slice(0, 8)}...{p.txHash.slice(-6)}
</a>
</td>
<td className="px-4 py-3">{formatAddress(p.fromAddress)}</td>
<td className="px-4 py-3">
{p.evmAddress ? (
<span className="text-xs font-mono text-green-400">
{p.evmAddress.slice(0, 8)}...{p.evmAddress.slice(-6)}
</span>
) : (
<span className="text-xs text-red-400"> No EVM addr</span>
)}
</td>
<td className="px-4 py-3 font-semibold" style={{ color: "#f0b429" }}>
${p.usdtAmount.toFixed(2)}
</td>
<td className="px-4 py-3 text-white/70">
{(p.xicAmount / 1e6).toFixed(2)}M
</td>
<td className="px-4 py-3">
<StatusBadge status={p.status} />
</td>
<td className="px-4 py-3 text-white/40 text-xs">
{new Date(p.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
{p.status === "confirmed" && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="TX hash (optional)"
value={distributeTxInput[p.id] || ""}
onChange={e => setDistributeTxInput(prev => ({ ...prev, [p.id]: e.target.value }))}
className="px-2 py-1 rounded text-xs w-32"
style={{ background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "white" }}
/>
<button
onClick={() => handleMarkDistributed(p.id)}
disabled={markingId === p.id}
className="px-3 py-1 rounded-lg text-xs font-semibold transition-all whitespace-nowrap"
style={{ background: "rgba(0,230,118,0.15)", border: "1px solid rgba(0,230,118,0.3)", color: "#00e676" }}
>
{markingId === p.id ? "..." : "Mark Distributed"}
</button>
</div>
)}
{p.status === "distributed" && p.distributeTxHash && (
<a
href={`https://bscscan.com/tx/${p.distributeTxHash}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs hover:underline"
style={{ color: "#00e676" }}
>
View TX
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{purchasesData && purchasesData.total > 20 && (
<div className="flex items-center justify-between px-5 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
<span className="text-xs text-white/40">
Showing {((page - 1) * 20) + 1}{Math.min(page * 20, purchasesData.total)} of {purchasesData.total}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all disabled:opacity-30"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "white" }}
>
Prev
</button>
<span className="px-3 py-1.5 text-xs text-white/60">Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page * 20 >= purchasesData.total}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all disabled:opacity-30"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "white" }}
>
Next
</button>
</div>
</div>
)}
</div>
)}
{/* ── Bridge Orders Panel ── */}
{activeTab === "bridge" && (
<div className="space-y-4">
{/* Bridge Intents */}
<div className="rounded-2xl overflow-hidden" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(138,71,229,0.2)" }}>
<div className="px-5 py-4" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="font-semibold text-white/80" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
Bridge Intents
<span className="text-white/40 text-sm ml-2">(users who registered intent but transfer not yet detected)</span>
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{["ID", "Chain", "Sender", "XIC Receive Addr", "Expected USDT", "Matched", "Time"].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/30">{h}</th>
))}
</tr>
</thead>
<tbody>
{bridgeData?.intents && bridgeData.intents.length > 0 ? bridgeData.intents.map((intent, i) => {
const chainNames: Record<number, string> = { 56: "BSC", 1: "ETH", 137: "POLY", 42161: "ARB", 43114: "AVAX" };
return (
<tr key={intent.id} style={{ borderBottom: i < bridgeData.intents.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none" }}>
<td className="px-4 py-3 text-white/50 text-xs">{intent.id}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full" style={{ background: "rgba(138,71,229,0.15)", color: "#a855f7" }}>
{chainNames[intent.fromChainId] ?? `Chain ${intent.fromChainId}`}
</span>
</td>
<td className="px-4 py-3">{formatAddress(intent.senderAddress ?? null)}</td>
<td className="px-4 py-3">{formatAddress(intent.xicReceiveAddress)}</td>
<td className="px-4 py-3 text-white/80">
{intent.expectedUsdt ? `$${intent.expectedUsdt.toLocaleString()}` : <span className="text-white/30"></span>}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${intent.matched ? "text-green-400" : "text-yellow-400"}`}
style={{ background: intent.matched ? "rgba(0,230,118,0.1)" : "rgba(240,180,41,0.1)" }}>
{intent.matched ? "Matched" : "Pending"}
</span>
</td>
<td className="px-4 py-3 text-white/30 text-xs">{formatDate(intent.createdAt)}</td>
</tr>
);
}) : (
<tr><td colSpan={7} className="px-4 py-8 text-center text-white/30 text-sm">No bridge intents yet</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Bridge Orders */}
<div className="rounded-2xl overflow-hidden" style={{ background: "rgba(255,255,255,0.02)", border: "1px solid rgba(255,255,255,0.06)" }}>
<div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<h3 className="font-semibold text-white/80" style={{ fontFamily: "'Space Grotesk', sans-serif" }}>
Bridge Orders
<span className="text-white/40 text-sm ml-2">(confirmed USDT deposits awaiting XIC distribution)</span>
</h3>
{/* Status Filter */}
<div className="flex gap-2">
{(["all", "pending", "confirmed", "distributed", "failed"] as const).map(s => (
<button
key={s}
onClick={() => { setBridgeStatusFilter(s); setBridgePage(1); }}
className="px-2 py-1 rounded-lg text-xs font-medium transition-all"
style={{
background: bridgeStatusFilter === s ? "rgba(138,71,229,0.2)" : "rgba(255,255,255,0.04)",
border: bridgeStatusFilter === s ? "1px solid rgba(138,71,229,0.4)" : "1px solid rgba(255,255,255,0.08)",
color: bridgeStatusFilter === s ? "#a855f7" : "rgba(255,255,255,0.4)",
}}
>
{s}
</button>
))}
</div>
</div>
{bridgeLoading ? (
<div className="flex justify-center py-12"><span className="text-white/30">Loading...</span></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
{["ID", "TX Hash", "Chain", "Sender", "USDT", "XIC", "XIC Receive Addr", "Status", "Distribute TX", "Action"].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/30">{h}</th>
))}
</tr>
</thead>
<tbody>
{bridgeData?.orders && bridgeData.orders.length > 0 ? bridgeData.orders.map((order, i) => {
const chainNames: Record<number, string> = { 56: "BSC", 1: "ETH", 137: "POLY", 42161: "ARB", 43114: "AVAX" };
const isUpdating = updatingBridgeId === order.id;
return (
<tr key={order.id} style={{ borderBottom: i < bridgeData.orders.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none" }}>
<td className="px-4 py-3 text-white/50 text-xs">{order.id}</td>
<td className="px-4 py-3">{formatAddress(order.txHash)}</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-full" style={{ background: "rgba(138,71,229,0.15)", color: "#a855f7" }}>
{chainNames[order.fromChainId] ?? `Chain ${order.fromChainId}`}
</span>
</td>
<td className="px-4 py-3">{formatAddress(order.walletAddress)}</td>
<td className="px-4 py-3 text-amber-400 font-semibold">${order.fromAmount.toLocaleString()}</td>
<td className="px-4 py-3 text-cyan-400 font-semibold">{order.toAmount.toLocaleString(undefined, { maximumFractionDigits: 0 })} XIC</td>
<td className="px-4 py-3">{formatAddress(order.xicReceiveAddress ?? null)}</td>
<td className="px-4 py-3"><StatusBadge status={order.status as any} /></td>
<td className="px-4 py-3">
{order.status !== "distributed" ? (
<input
type="text"
value={bridgeDistribTxInput[order.id] || ""}
onChange={e => setBridgeDistribTxInput(prev => ({ ...prev, [order.id]: e.target.value }))}
placeholder="0x... BSC TX hash"
className="w-32 rounded px-2 py-1 text-xs text-white outline-none"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", fontFamily: "'JetBrains Mono', monospace" }}
/>
) : (
formatAddress(order.distributeTxHash ?? null)
)}
</td>
<td className="px-4 py-3">
{order.status === "confirmed" && (
<button
onClick={() => handleUpdateBridgeOrder(order.id, "distributed")}
disabled={isUpdating}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: isUpdating ? "rgba(0,230,118,0.1)" : "rgba(0,230,118,0.2)",
border: "1px solid rgba(0,230,118,0.4)",
color: "#00e676",
cursor: isUpdating ? "not-allowed" : "pointer",
}}
>
{isUpdating ? "..." : "Mark Distributed"}
</button>
)}
{order.status === "pending" && (
<button
onClick={() => handleUpdateBridgeOrder(order.id, "confirmed")}
disabled={isUpdating}
className="px-3 py-1.5 rounded-lg text-xs font-semibold transition-all"
style={{
background: "rgba(0,212,255,0.15)",
border: "1px solid rgba(0,212,255,0.3)",
color: "#00d4ff",
}}
>
{isUpdating ? "..." : "Confirm"}
</button>
)}
</td>
</tr>
);
}) : (
<tr><td colSpan={10} className="px-4 py-8 text-center text-white/30 text-sm">No bridge orders yet</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{bridgeData && bridgeData.total > 20 && (
<div className="flex items-center justify-between px-5 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
<span className="text-xs text-white/30">{bridgeData.total} total orders</span>
<div className="flex gap-2">
<button
onClick={() => setBridgePage(p => Math.max(1, p - 1))}
disabled={bridgePage === 1}
className="px-3 py-1.5 rounded-lg text-xs transition-all"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: bridgePage === 1 ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.6)" }}
>
Prev
</button>
<span className="px-3 py-1.5 text-xs text-white/40">Page {bridgePage} / {Math.ceil(bridgeData.total / 20)}</span>
<button
onClick={() => setBridgePage(p => p + 1)}
disabled={bridgePage >= Math.ceil(bridgeData.total / 20)}
className="px-3 py-1.5 rounded-lg text-xs transition-all"
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: bridgePage >= Math.ceil(bridgeData.total / 20) ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.6)" }}
>
Next
</button>
</div>
</div>
)}
</div>
</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">
<p>1. <strong className="text-white/80">Confirmed</strong> = TRC20 USDT received, waiting for XIC distribution</p>
<p>2. Check if buyer provided an EVM address (0x...) shown in "EVM Address" column</p>
<p>3. Send XIC tokens from operator wallet to buyer's EVM address on BSC</p>
<p>4. Enter the BSC distribution TX hash and click <strong className="text-white/80">"Mark Distributed"</strong></p>
<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>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function Admin() {
const [token, setToken] = useState<string | null>(() => {
return sessionStorage.getItem("nac-admin-token");
});
const handleLogin = (t: string) => {
sessionStorage.setItem("nac-admin-token", t);
setToken(t);
};
const handleLogout = () => {
sessionStorage.removeItem("nac-admin-token");
setToken(null);
};
if (!token) {
return <LoginForm onLogin={handleLogin} />;
}
return <Dashboard token={token} onLogout={handleLogout} />;
}