1116 lines
54 KiB
TypeScript
1116 lines
54 KiB
TypeScript
/**
|
||
* 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<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" | "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} />;
|
||
}
|