Backend (FastAPI + SQLAlchemy + SQLite): - JWT auth with access/refresh tokens, bcrypt password hashing - User model with member/organizer/admin roles, auto-approve members - Championship, Registration, ParticipantList, Notification models - Alembic async migrations, seed data with test users - Registration endpoint returns tokens for members, pending for organizers - /registrations/my returns championship title/date/location via eager loading - Admin endpoints: list users, approve/reject organizers Mobile (React Native + Expo + TypeScript): - Zustand auth store, Axios client with token refresh interceptor - Role-based registration (Member vs Organizer) with contextual form labels - Tab navigation with Ionicons, safe area headers, admin tab for admin role - Championships list with status badges, detail screen with registration progress - My Registrations with championship title, progress bar, and tap-to-navigate - Admin panel with pending/all filter, approve/reject with confirmation - Profile screen with role badge, Ionicons info rows, sign out - Password visibility toggle (Ionicons), keyboard flow hints (returnKeyType) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
518 lines
36 KiB
JavaScript
518 lines
36 KiB
JavaScript
import { useState } from "react";
|
||
|
||
/* ── Platform Data ── */
|
||
const PLATFORM = { name: "DanceChamp", version: "1.0 MVP", totalRevenue: "12,450 BYN" };
|
||
|
||
const ORGS_DATA = [
|
||
{ id: "o1", name: "Zero Gravity Team", instagram: "@zerogravity_pole", logo: "💃", status: "active", joined: "Jan 15, 2026", champsCount: 2, membersCount: 24, city: "Minsk", email: "team@zerogravity.by", verified: true },
|
||
{ id: "o2", name: "Pole Universe", instagram: "@pole_universe", logo: "🌌", status: "active", joined: "Feb 2, 2026", champsCount: 1, membersCount: 12, city: "Moscow", email: "info@poleuniverse.ru", verified: true },
|
||
{ id: "o3", name: "Sky Pole Studio", instagram: "@sky_pole", logo: "☁️", status: "pending", joined: "Feb 20, 2026", champsCount: 0, membersCount: 0, city: "St. Petersburg", email: "hello@skypole.ru", verified: false },
|
||
{ id: "o4", name: "Dance Flames", instagram: "@dance_flames", logo: "🔥", status: "blocked", joined: "Dec 10, 2025", champsCount: 1, membersCount: 5, city: "Kyiv", email: "admin@danceflames.ua", verified: false, blockReason: "Fake organization — no real events" },
|
||
];
|
||
|
||
const CHAMPS_DATA = [
|
||
{ id: "c1", orgId: "o1", orgName: "Zero Gravity Team", name: "Zero Gravity", dates: "May 30, 2026", location: "Minsk", status: "live", members: 24, passed: 8, pending: 8, revenue: "4,200 BYN", orgVerified: true },
|
||
{ id: "c2", orgId: "o1", orgName: "Zero Gravity Team", name: "Pole Star", dates: "Jul 12, 2026", location: "Moscow", status: "draft", members: 1, passed: 0, pending: 0, revenue: "0", orgVerified: true },
|
||
{ id: "c3", orgId: "o2", orgName: "Pole Universe", name: "Galactic Pole", dates: "Sep 15, 2026", location: "Moscow", status: "live", members: 12, passed: 0, pending: 12, revenue: "1,800 BYN", orgVerified: true },
|
||
{ id: "c4", orgId: "o3", orgName: "Sky Pole Studio", name: "Sky Open", dates: "Oct 5, 2026", location: "St. Petersburg", status: "pending_approval", members: 0, passed: 0, pending: 0, revenue: "0", orgVerified: false },
|
||
{ id: "c5", orgId: "o4", orgName: "Dance Flames", name: "Fire Cup", dates: "Mar 1, 2026", location: "Kyiv", status: "blocked", members: 5, passed: 0, pending: 0, revenue: "250 BYN", orgVerified: false },
|
||
];
|
||
|
||
const USERS_DATA = [
|
||
{ id: "u1", name: "Alex Petrova", instagram: "@alex_pole", email: "alex@mail.ru", city: "Moscow", joined: "Jan 20, 2026", champsJoined: 2, status: "active", role: "member" },
|
||
{ id: "u2", name: "Maria Ivanova", instagram: "@maria_exotic", email: "maria@gmail.com", city: "Minsk", joined: "Jan 22, 2026", champsJoined: 1, status: "active", role: "member" },
|
||
{ id: "u3", name: "Elena Kozlova", instagram: "@elena.pole", email: "elena@ya.ru", city: "St. Petersburg", joined: "Feb 1, 2026", champsJoined: 1, status: "active", role: "member" },
|
||
{ id: "u4", name: "Daria Sokolova", instagram: "@daria_art", email: "daria@mail.ru", city: "Kyiv", joined: "Feb 5, 2026", champsJoined: 1, status: "active", role: "member" },
|
||
{ id: "u5", name: "Anna Belova", instagram: "@anna.b_pole", email: "anna@gmail.com", city: "Minsk", joined: "Feb 10, 2026", champsJoined: 1, status: "active", role: "member" },
|
||
{ id: "u6", name: "Olga Morozova", instagram: "@olga_exotic", email: "olga@mail.ru", city: "Moscow", joined: "Feb 12, 2026", champsJoined: 3, status: "warned", role: "member", warnReason: "Disputed payment — under review" },
|
||
{ id: "u7", name: "Ivan Petrov", instagram: "@ivan_admin", email: "ivan@zerogravity.by", city: "Minsk", joined: "Jan 10, 2026", champsJoined: 0, status: "active", role: "org_admin", org: "Zero Gravity Team" },
|
||
{ id: "u8", name: "Spam Bot", instagram: "@totally_real", email: "spam@fake.com", city: "Unknown", joined: "Feb 22, 2026", champsJoined: 0, status: "blocked", role: "member", blockReason: "Spam account" },
|
||
];
|
||
|
||
const LOGS_DATA = [
|
||
{ id: "l1", action: "Org approved & verified", target: "Pole Universe", by: "Admin", date: "Feb 2, 2026", type: "org" },
|
||
{ id: "l2", action: "User blocked", target: "Spam Bot", by: "Admin", date: "Feb 22, 2026", type: "user" },
|
||
{ id: "l3", action: "Org blocked", target: "Dance Flames", by: "Admin", date: "Feb 23, 2026", type: "org" },
|
||
{ id: "l4", action: "Champ auto-approved (verified org)", target: "Zero Gravity", by: "System", date: "Feb 1, 2026", type: "champ" },
|
||
{ id: "l5", action: "User warned", target: "Olga Morozova", by: "Admin", date: "Feb 20, 2026", type: "user" },
|
||
{ id: "l6", action: "New org registered (pending)", target: "Sky Pole Studio", by: "System", date: "Feb 20, 2026", type: "org" },
|
||
{ id: "l7", action: "Champ submitted for review", target: "Sky Open", by: "Sky Pole Studio", date: "Feb 21, 2026", type: "champ" },
|
||
];
|
||
|
||
/* ── Theme (admin = darker, more neutral accent) ── */
|
||
const c = { bg: "#07060C", card: "#111019", cardH: "#18172290", brd: "#1D1C2B", text: "#F2F0FA", dim: "#5E5C72", mid: "#8F8DA6", accent: "#6366F1", accentS: "rgba(99,102,241,0.10)", green: "#10B981", greenS: "rgba(16,185,129,0.10)", yellow: "#F59E0B", yellowS: "rgba(245,158,11,0.10)", purple: "#8B5CF6", purpleS: "rgba(139,92,246,0.10)", blue: "#60A5FA", blueS: "rgba(96,165,250,0.10)", red: "#EF4444", redS: "rgba(239,68,68,0.10)", orange: "#F97316", orangeS: "rgba(249,115,22,0.10)" };
|
||
const f = { d: "'Playfair Display',Georgia,serif", b: "'DM Sans','Segoe UI',sans-serif", m: "'JetBrains Mono',monospace" };
|
||
|
||
/* ── Shared UI ── */
|
||
const Cd = ({ children, style: s }) => <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 16, ...s }}>{children}</div>;
|
||
const ST = ({ children, right }) => <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", margin: "0 0 10px" }}><h3 style={{ fontFamily: f.d, fontSize: 14, fontWeight: 700, color: c.mid, margin: 0 }}>{children}</h3>{right}</div>;
|
||
const Bg = ({ label, color, bg }) => <span style={{ fontFamily: f.m, fontSize: 8, fontWeight: 700, letterSpacing: 0.8, color, background: bg, padding: "3px 8px", borderRadius: 4 }}>{label}</span>;
|
||
|
||
const statusConfig = {
|
||
active: { l: "ACTIVE", c: c.green, b: c.greenS }, live: { l: "LIVE", c: c.green, b: c.greenS },
|
||
pending: { l: "PENDING", c: c.yellow, b: c.yellowS }, pending_approval: { l: "AWAITING REVIEW", c: c.orange, b: c.orangeS },
|
||
draft: { l: "DRAFT", c: c.dim, b: `${c.dim}15` },
|
||
blocked: { l: "BLOCKED", c: c.red, b: c.redS }, warned: { l: "WARNED", c: c.orange, b: c.orangeS },
|
||
};
|
||
|
||
function Hdr({ title, subtitle, onBack, right }) {
|
||
return <div style={{ padding: "14px 20px 6px", display: "flex", alignItems: "center", gap: 12 }}>
|
||
{onBack && <div onClick={onBack} style={{ width: 32, height: 32, borderRadius: 8, background: c.card, border: `1px solid ${c.brd}`, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", fontSize: 15, color: c.text }}>←</div>}
|
||
<div style={{ flex: 1, minWidth: 0 }}><h1 style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: c.text, margin: 0 }}>{title}</h1>{subtitle && <p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>{subtitle}</p>}</div>
|
||
{right}
|
||
</div>;
|
||
}
|
||
|
||
function Nav({ active, onChange }) {
|
||
return <div style={{ display: "flex", justifyContent: "space-around", padding: "10px 0 8px", borderTop: `1px solid ${c.brd}`, background: c.bg, flexShrink: 0 }}>
|
||
{[{ id: "dash", i: "📊", l: "Overview" }, { id: "orgs", i: "🏢", l: "Orgs" }, { id: "champs", i: "🏆", l: "Champs" }, { id: "users", i: "👥", l: "Users" }].map(x =>
|
||
<div key={x.id} onClick={() => onChange(x.id)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 2, cursor: "pointer", opacity: active === x.id ? 1 : 0.35 }}><span style={{ fontSize: 18 }}>{x.i}</span><span style={{ fontFamily: f.m, fontSize: 8, color: c.text, letterSpacing: 0.3 }}>{x.l}</span></div>
|
||
)}
|
||
</div>;
|
||
}
|
||
|
||
function SearchBar({ value, onChange, placeholder }) {
|
||
return <div style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 14px", display: "flex", alignItems: "center", gap: 10 }}>
|
||
<span style={{ fontSize: 14, opacity: 0.4 }}>🔍</span>
|
||
<input type="text" placeholder={placeholder || "Search..."} value={value} onChange={e => onChange(e.target.value)} style={{ background: "transparent", border: "none", outline: "none", color: c.text, fontFamily: f.b, fontSize: 13, width: "100%" }} />
|
||
</div>;
|
||
}
|
||
|
||
function FilterChips({ filters, active, onChange, accent }) {
|
||
return <div style={{ display: "flex", gap: 4, overflowX: "auto", scrollbarWidth: "none" }}>
|
||
{filters.map(fi => <div key={fi.id} onClick={() => onChange(fi.id)} style={{ fontFamily: f.m, fontSize: 9, fontWeight: 600, whiteSpace: "nowrap", color: active === fi.id ? accent || c.accent : c.dim, background: active === fi.id ? `${accent || c.accent}15` : "transparent", border: `1px solid ${active === fi.id ? `${accent || c.accent}30` : "transparent"}`, padding: "5px 10px", borderRadius: 16, cursor: "pointer" }}>{fi.l}{fi.n !== undefined ? ` (${fi.n})` : ""}</div>)}
|
||
</div>;
|
||
}
|
||
|
||
function ActionBtn({ label, color, onClick, icon, filled }) {
|
||
return <div onClick={onClick} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 5, padding: "8px 14px", borderRadius: 8, background: filled ? color : `${color}15`, border: `1px solid ${filled ? color : `${color}30`}`, cursor: "pointer" }}>
|
||
{icon && <span style={{ fontSize: 12 }}>{icon}</span>}
|
||
<span style={{ fontFamily: f.b, fontSize: 11, fontWeight: 700, color: filled ? "#fff" : color }}>{label}</span>
|
||
</div>;
|
||
}
|
||
|
||
/* ── Dashboard ── */
|
||
function Dashboard({ orgs, champs, users, onNav }) {
|
||
const pendingOrgs = orgs.filter(o => o.status === "pending").length;
|
||
const pendingChamps = champs.filter(c2 => c2.status === "pending_approval").length;
|
||
const blockedUsers = users.filter(u => u.status === "blocked").length;
|
||
|
||
return <div>
|
||
<Hdr title="Admin Panel" subtitle={`${PLATFORM.name} · ${PLATFORM.version}`} right={
|
||
<div style={{ width: 36, height: 36, borderRadius: 10, background: `linear-gradient(135deg,${c.accent}25,${c.accent}10)`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 14, fontFamily: f.m, fontWeight: 700, color: c.accent }}>⚡</div>
|
||
} />
|
||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||
|
||
{/* Platform stats */}
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
{[{ n: orgs.filter(o => o.status === "active").length, l: "Orgs", co: c.accent, go: "orgs" },
|
||
{ n: champs.filter(c2 => c2.status === "live").length, l: "Live Champs", co: c.green, go: "champs" },
|
||
{ n: users.length, l: "Users", co: c.blue, go: "users" },
|
||
].map(s => <div key={s.l} onClick={() => onNav(s.go)} style={{ flex: 1, background: c.card, border: `1px solid ${c.brd}`, borderRadius: 12, padding: "10px 6px", textAlign: "center", cursor: "pointer" }}>
|
||
<p style={{ fontFamily: f.d, fontSize: 20, fontWeight: 700, color: s.co, margin: 0 }}>{s.n}</p>
|
||
<p style={{ fontFamily: f.m, fontSize: 7, color: c.dim, margin: "2px 0 0", textTransform: "uppercase" }}>{s.l}</p>
|
||
</div>)}
|
||
</div>
|
||
|
||
{/* Needs attention */}
|
||
{(pendingOrgs > 0 || pendingChamps > 0) && <Cd style={{ background: `${c.yellow}06`, border: `1px solid ${c.yellow}20` }}>
|
||
<ST right={<Bg label="ACTION" color={c.yellow} bg={c.yellowS} />}>⚡ Needs Attention</ST>
|
||
{pendingOrgs > 0 && <div onClick={() => onNav("orgs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", borderBottom: `1px solid ${c.brd}`, cursor: "pointer" }}>
|
||
<span style={{ fontSize: 16 }}>🏢</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Organizations awaiting approval</span>
|
||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingOrgs}</span>
|
||
<span style={{ color: c.dim }}>›</span>
|
||
</div>}
|
||
{pendingChamps > 0 && <div onClick={() => onNav("champs")} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 0", cursor: "pointer" }}>
|
||
<span style={{ fontSize: 16 }}>🏆</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 13, color: c.text, flex: 1 }}>Champs awaiting approval (unverified orgs)</span>
|
||
<span style={{ fontFamily: f.m, fontSize: 14, fontWeight: 700, color: c.yellow }}>{pendingChamps}</span>
|
||
<span style={{ color: c.dim }}>›</span>
|
||
</div>}
|
||
</Cd>}
|
||
|
||
{/* Quick stats */}
|
||
<Cd>
|
||
<ST>Platform Health</ST>
|
||
{[{ l: "Total revenue", v: PLATFORM.totalRevenue, co: c.green },
|
||
{ l: "Active orgs", v: `${orgs.filter(o => o.status === "active").length}/${orgs.length}`, co: c.accent },
|
||
{ l: "Blocked users", v: `${blockedUsers}`, co: blockedUsers > 0 ? c.red : c.green },
|
||
{ l: "Avg members/champ", v: Math.round(users.filter(u => u.role === "member").length / Math.max(champs.filter(c2 => c2.status === "live").length, 1)), co: c.blue },
|
||
].map(s => <div key={s.l} style={{ display: "flex", justifyContent: "space-between", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.mid }}>{s.l}</span>
|
||
<span style={{ fontFamily: f.m, fontSize: 13, fontWeight: 700, color: s.co }}>{s.v}</span>
|
||
</div>)}
|
||
</Cd>
|
||
|
||
{/* Recent activity */}
|
||
<Cd>
|
||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{LOGS_DATA.length} entries</span>}>Recent Activity</ST>
|
||
{LOGS_DATA.slice(0, 5).map(log => {
|
||
const tc = { org: c.accent, user: c.blue, champ: c.green }[log.type] || c.dim;
|
||
return <div key={log.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||
<div style={{ width: 6, height: 6, borderRadius: 3, background: tc, flexShrink: 0 }} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{log.action}: <span style={{ color: tc }}>{log.target}</span></p>
|
||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.dim, margin: "2px 0 0" }}>{log.date} · {log.by}</p>
|
||
</div>
|
||
</div>;
|
||
})}
|
||
</Cd>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── Organizations ── */
|
||
function OrgsList({ orgs, onOrgTap }) {
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState("all");
|
||
|
||
const filters = [
|
||
{ id: "all", l: "All", n: orgs.length },
|
||
{ id: "active", l: "✅ Active", n: orgs.filter(o => o.status === "active").length },
|
||
{ id: "pending", l: "⏳ Pending", n: orgs.filter(o => o.status === "pending").length },
|
||
{ id: "blocked", l: "🚫 Blocked", n: orgs.filter(o => o.status === "blocked").length },
|
||
];
|
||
|
||
const filtered = orgs.filter(o => {
|
||
const q = !search || o.name.toLowerCase().includes(search.toLowerCase()) || o.instagram.toLowerCase().includes(search.toLowerCase());
|
||
if (!q) return false;
|
||
if (filter === "active") return o.status === "active";
|
||
if (filter === "pending") return o.status === "pending";
|
||
if (filter === "blocked") return o.status === "blocked";
|
||
return true;
|
||
});
|
||
|
||
return <div>
|
||
<Hdr title="Organizations" subtitle={`${orgs.length} registered`} />
|
||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<SearchBar value={search} onChange={setSearch} placeholder="Search org name or @handle..." />
|
||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||
{filtered.map(o => {
|
||
const st = statusConfig[o.status] || statusConfig.active;
|
||
return <div key={o.id} onClick={() => onOrgTap(o)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||
<div style={{ width: 40, height: 40, borderRadius: 10, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 18 }}>{o.logo}</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
|
||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||
</div>
|
||
<p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 12, marginTop: 8, paddingTop: 8, borderTop: `1px solid ${c.brd}` }}>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {o.city}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>🏆 {o.champsCount} champs</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {o.membersCount} members</span>
|
||
</div>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── Org Detail ── */
|
||
function OrgDetail({ org: initial, onBack, champs }) {
|
||
const [o, setO] = useState(initial);
|
||
const st = statusConfig[o.status] || statusConfig.active;
|
||
const orgChamps = champs.filter(c2 => c2.orgId === o.id);
|
||
|
||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||
<Hdr title={o.name} subtitle={o.instagram} onBack={onBack} />
|
||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||
{/* Profile */}
|
||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||
<div style={{ width: 54, height: 54, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24 }}>{o.logo}</div>
|
||
<div style={{ flex: 1 }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{o.name}</p>
|
||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{o.instagram}</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📍 {o.city} · 📧 {o.email}</p>
|
||
</div>
|
||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||
</Cd>
|
||
|
||
{/* Info */}
|
||
<Cd>
|
||
<ST>Details</ST>
|
||
{[{ l: "Joined", v: o.joined }, { l: "Championships", v: o.champsCount }, { l: "Total members", v: o.membersCount }, { l: "Verified", v: o.verified ? "✅ Yes" : "❌ No" }].map(r =>
|
||
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||
</div>
|
||
)}
|
||
</Cd>
|
||
|
||
{/* Approval policy */}
|
||
<Cd style={{ background: o.verified ? `${c.green}06` : `${c.yellow}06`, border: `1px solid ${o.verified ? `${c.green}20` : `${c.yellow}20`}` }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
<span style={{ fontSize: 18 }}>{o.verified ? "🛡️" : "⏳"}</span>
|
||
<div>
|
||
<p style={{ fontFamily: f.b, fontSize: 12, fontWeight: 600, color: o.verified ? c.green : c.yellow, margin: 0 }}>{o.verified ? "Verified — Auto-approve events" : "Unverified — Events need manual approval"}</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{o.verified ? "Championships go live instantly when org clicks 'Go Live'" : "Admin must review & approve each championship before it becomes visible"}</p>
|
||
</div>
|
||
</div>
|
||
</Cd>
|
||
|
||
{/* Championships */}
|
||
{orgChamps.length > 0 && <Cd>
|
||
<ST right={<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim }}>{orgChamps.length}</span>}>Championships</ST>
|
||
{orgChamps.map(ch => {
|
||
const cs = statusConfig[ch.status] || statusConfig.draft;
|
||
return <div key={ch.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||
<div><p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.dates} · {ch.location}</p></div>
|
||
<Bg label={cs.l} color={cs.c} bg={cs.b} />
|
||
</div>;
|
||
})}
|
||
</Cd>}
|
||
|
||
{/* Block reason */}
|
||
{o.blockReason && <Cd style={{ background: `${c.red}06`, border: `1px solid ${c.red}20` }}>
|
||
<p style={{ fontFamily: f.m, fontSize: 9, color: c.red, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>Block Reason</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{o.blockReason}</p>
|
||
</Cd>}
|
||
|
||
{/* Actions */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{o.status === "pending" && <div style={{ display: "flex", gap: 8 }}>
|
||
<ActionBtn label="Approve" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", verified: true }))} icon="✅" filled />
|
||
<ActionBtn label="Reject" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Rejected by admin" }))} icon="❌" filled />
|
||
</div>}
|
||
{o.status === "active" && <ActionBtn label="Block Organization" color={c.red} onClick={() => setO(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />}
|
||
{o.status === "blocked" && <ActionBtn label="Unblock Organization" color={c.green} onClick={() => setO(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
|
||
{!o.verified && o.status !== "blocked" && <ActionBtn label="Verify Organization" color={c.accent} onClick={() => setO(p => ({ ...p, verified: true }))} icon="🛡️" />}
|
||
<ActionBtn label="Delete Organization" color={c.red} onClick={() => {}} icon="🗑️" />
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── Championships ── */
|
||
function ChampsList({ champs, onChampTap }) {
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState("all");
|
||
|
||
const filters = [
|
||
{ id: "all", l: "All", n: champs.length },
|
||
{ id: "live", l: "🟢 Live", n: champs.filter(c2 => c2.status === "live").length },
|
||
{ id: "pending_approval", l: "⏳ Awaiting", n: champs.filter(c2 => c2.status === "pending_approval").length },
|
||
{ id: "draft", l: "📝 Draft", n: champs.filter(c2 => c2.status === "draft").length },
|
||
{ id: "blocked", l: "🚫 Blocked", n: champs.filter(c2 => c2.status === "blocked").length },
|
||
];
|
||
|
||
const filtered = champs.filter(ch => {
|
||
const q = !search || ch.name.toLowerCase().includes(search.toLowerCase()) || ch.orgName.toLowerCase().includes(search.toLowerCase());
|
||
if (!q) return false;
|
||
if (filter !== "all") return ch.status === filter;
|
||
return true;
|
||
});
|
||
|
||
return <div>
|
||
<Hdr title="Championships" subtitle={`${champs.length} total`} />
|
||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<SearchBar value={search} onChange={setSearch} placeholder="Search championship or org..." />
|
||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||
{filtered.map(ch => {
|
||
const st = statusConfig[ch.status] || statusConfig.draft;
|
||
return <div key={ch.id} onClick={() => onChampTap(ch)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 14, cursor: "pointer" }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 4 }}>
|
||
<div><p style={{ fontFamily: f.b, fontSize: 14, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p><p style={{ fontFamily: f.m, fontSize: 10, color: c.accent, margin: "2px 0 0" }}>by {ch.orgName} {ch.orgVerified ? "🛡️" : ""}</p></div>
|
||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||
</div>
|
||
<div style={{ display: "flex", gap: 12, marginTop: 6 }}>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📅 {ch.dates}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>📍 {ch.location}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.mid }}>👥 {ch.members}</span>
|
||
</div>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── Championship Detail ── */
|
||
function ChampDetail({ ch: initial, onBack }) {
|
||
const [ch, setCh] = useState(initial);
|
||
const st = statusConfig[ch.status] || statusConfig.draft;
|
||
|
||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||
<Hdr title={ch.name} subtitle={`by ${ch.orgName}`} onBack={onBack} />
|
||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.accent}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>🏆</div>
|
||
<div style={{ flex: 1 }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{ch.name}</p>
|
||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{ch.orgName}</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📅 {ch.dates} · 📍 {ch.location}</p>
|
||
</div>
|
||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||
</Cd>
|
||
|
||
<Cd>
|
||
<ST>Stats</ST>
|
||
{[{ l: "Members", v: ch.members }, { l: "Passed", v: ch.passed }, { l: "Pending", v: ch.pending }, { l: "Revenue", v: ch.revenue }].map(r =>
|
||
<div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||
</div>
|
||
)}
|
||
</Cd>
|
||
|
||
{/* Approval info */}
|
||
{ch.orgVerified !== undefined && <Cd>
|
||
<ST>Approval Policy</ST>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 0" }}>
|
||
<span style={{ fontSize: 16 }}>{ch.orgVerified ? "🛡️" : "⏳"}</span>
|
||
<div style={{ flex: 1 }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{ch.orgVerified ? "Verified org — auto-approved" : "Unverified org — manual review required"}</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 10, color: c.dim, margin: "2px 0 0" }}>{ch.orgVerified ? "This org can go live without admin approval" : "Admin must approve before members can see this event"}</p>
|
||
</div>
|
||
</div>
|
||
</Cd>}
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{ch.status === "pending_approval" && <div style={{ display: "flex", gap: 8 }}>
|
||
<ActionBtn label="Approve" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" filled />
|
||
<ActionBtn label="Reject" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="❌" filled />
|
||
</div>}
|
||
{ch.status === "live" && <ActionBtn label="Suspend Event" color={c.red} onClick={() => setCh(p => ({ ...p, status: "blocked" }))} icon="⏸️" />}
|
||
{ch.status === "blocked" && <ActionBtn label="Reinstate Event" color={c.green} onClick={() => setCh(p => ({ ...p, status: "live" }))} icon="✅" />}
|
||
<ActionBtn label="Delete Championship" color={c.red} onClick={() => {}} icon="🗑️" />
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── Users ── */
|
||
function UsersList({ users, onUserTap }) {
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState("all");
|
||
|
||
const filters = [
|
||
{ id: "all", l: "All", n: users.length },
|
||
{ id: "active", l: "✅ Active", n: users.filter(u => u.status === "active").length },
|
||
{ id: "warned", l: "⚠️ Warned", n: users.filter(u => u.status === "warned").length },
|
||
{ id: "blocked", l: "🚫 Blocked", n: users.filter(u => u.status === "blocked").length },
|
||
{ id: "org_admin", l: "🏢 Org Admins", n: users.filter(u => u.role === "org_admin").length },
|
||
];
|
||
|
||
const filtered = users.filter(u => {
|
||
const q = !search || u.name.toLowerCase().includes(search.toLowerCase()) || u.instagram.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
||
if (!q) return false;
|
||
if (filter === "active") return u.status === "active";
|
||
if (filter === "warned") return u.status === "warned";
|
||
if (filter === "blocked") return u.status === "blocked";
|
||
if (filter === "org_admin") return u.role === "org_admin";
|
||
return true;
|
||
});
|
||
|
||
return <div>
|
||
<Hdr title="Users" subtitle={`${users.length} total`} />
|
||
<div style={{ padding: "6px 16px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||
<SearchBar value={search} onChange={setSearch} placeholder="Search name, @handle, or email..." />
|
||
<FilterChips filters={filters} active={filter} onChange={setFilter} />
|
||
{filtered.map(u => {
|
||
const st = statusConfig[u.status] || statusConfig.active;
|
||
return <div key={u.id} onClick={() => onUserTap(u)} style={{ background: c.card, border: `1px solid ${c.brd}`, borderRadius: 14, padding: 12, cursor: "pointer" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||
<div style={{ width: 38, height: 38, borderRadius: 10, background: u.role === "org_admin" ? `${c.purple}15` : `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 16 }}>{u.role === "org_admin" ? "🏢" : "👤"}</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 13, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
|
||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
|
||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.accent }}>{u.instagram}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 10, color: c.dim }}>{u.city}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── User Detail ── */
|
||
function UserDetail({ user: initial, onBack }) {
|
||
const [u, setU] = useState(initial);
|
||
const st = statusConfig[u.status] || statusConfig.active;
|
||
|
||
return <div style={{ flex: 1, overflow: "auto" }}>
|
||
<Hdr title={u.name} subtitle={u.instagram} onBack={onBack} />
|
||
<div style={{ padding: "6px 16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
|
||
<Cd style={{ display: "flex", alignItems: "center", gap: 14, padding: "14px 16px" }}>
|
||
<div style={{ width: 50, height: 50, borderRadius: 14, background: `${c.blue}15`, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 22 }}>👤</div>
|
||
<div style={{ flex: 1 }}>
|
||
<p style={{ fontFamily: f.b, fontSize: 16, fontWeight: 600, color: c.text, margin: 0 }}>{u.name}</p>
|
||
<p style={{ fontFamily: f.m, fontSize: 11, color: c.accent, margin: "2px 0 0" }}>{u.instagram}</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 11, color: c.dim, margin: "2px 0 0" }}>📧 {u.email}</p>
|
||
</div>
|
||
<Bg label={st.l} color={st.c} bg={st.b} />
|
||
</Cd>
|
||
|
||
<Cd>
|
||
<ST>Info</ST>
|
||
{[{ l: "City", v: u.city }, { l: "Joined", v: u.joined }, { l: "Championships", v: u.champsJoined },
|
||
{ l: "Role", v: u.role === "org_admin" ? `Org Admin (${u.org})` : "Member" },
|
||
].map(r => <div key={r.l} style={{ display: "flex", justifyContent: "space-between", padding: "7px 0", borderBottom: `1px solid ${c.brd}` }}>
|
||
<span style={{ fontFamily: f.m, fontSize: 10, color: c.dim, textTransform: "uppercase" }}>{r.l}</span>
|
||
<span style={{ fontFamily: f.b, fontSize: 12, color: c.text }}>{r.v}</span>
|
||
</div>)}
|
||
</Cd>
|
||
|
||
{(u.blockReason || u.warnReason) && <Cd style={{ background: `${u.status === "blocked" ? c.red : c.orange}06`, border: `1px solid ${u.status === "blocked" ? c.red : c.orange}20` }}>
|
||
<p style={{ fontFamily: f.m, fontSize: 9, color: u.status === "blocked" ? c.red : c.orange, margin: "0 0 4px", textTransform: "uppercase", letterSpacing: 0.5 }}>{u.status === "blocked" ? "Block" : "Warning"} Reason</p>
|
||
<p style={{ fontFamily: f.b, fontSize: 12, color: c.text, margin: 0 }}>{u.blockReason || u.warnReason}</p>
|
||
</Cd>}
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{u.status === "active" && <>
|
||
<ActionBtn label="Warn User" color={c.orange} onClick={() => setU(p => ({ ...p, status: "warned", warnReason: "Warning issued by admin" }))} icon="⚠️" />
|
||
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked by admin" }))} icon="🚫" />
|
||
</>}
|
||
{u.status === "warned" && <>
|
||
<ActionBtn label="Remove Warning" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", warnReason: null }))} icon="✅" />
|
||
<ActionBtn label="Block User" color={c.red} onClick={() => setU(p => ({ ...p, status: "blocked", blockReason: "Blocked after warning" }))} icon="🚫" />
|
||
</>}
|
||
{u.status === "blocked" && <ActionBtn label="Unblock User" color={c.green} onClick={() => setU(p => ({ ...p, status: "active", blockReason: null }))} icon="✅" />}
|
||
<ActionBtn label="Delete User" color={c.red} onClick={() => {}} icon="🗑️" />
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
/* ── App Shell ── */
|
||
export default function AdminApp() {
|
||
const [scr, setScr] = useState("dash");
|
||
const [sel, setSel] = useState(null);
|
||
|
||
const go = (screen, data) => { setScr(screen); setSel(data || null); };
|
||
|
||
const render = () => {
|
||
if (scr === "orgDetail" && sel) return <OrgDetail org={sel} onBack={() => go("orgs")} champs={CHAMPS_DATA} />;
|
||
if (scr === "champDetail" && sel) return <ChampDetail ch={sel} onBack={() => go("champs")} />;
|
||
if (scr === "userDetail" && sel) return <UserDetail user={sel} onBack={() => go("users")} />;
|
||
if (scr === "orgs") return <OrgsList orgs={ORGS_DATA} onOrgTap={o => go("orgDetail", o)} />;
|
||
if (scr === "champs") return <ChampsList champs={CHAMPS_DATA} onChampTap={ch => go("champDetail", ch)} />;
|
||
if (scr === "users") return <UsersList users={USERS_DATA} onUserTap={u => go("userDetail", u)} />;
|
||
return <Dashboard orgs={ORGS_DATA} champs={CHAMPS_DATA} users={USERS_DATA} onNav={setScr} />;
|
||
};
|
||
|
||
const showNav = ["dash", "orgs", "champs", "users"].includes(scr);
|
||
|
||
return <div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh", background: "#020106", padding: 20, fontFamily: f.b }}>
|
||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||
<style>{`*::-webkit-scrollbar{display:none}*{scrollbar-width:none}`}</style>
|
||
<div style={{ width: 375, height: 740, background: c.bg, borderRadius: 36, overflow: "hidden", display: "flex", flexDirection: "column", border: `1.5px solid ${c.brd}`, boxShadow: `0 0 80px rgba(99,102,241,0.06),0 20px 40px rgba(0,0,0,0.5)` }}>
|
||
<div style={{ padding: "8px 24px", display: "flex", justifyContent: "space-between", alignItems: "center", flexShrink: 0 }}>
|
||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>9:41</span>
|
||
<div style={{ width: 100, height: 28, background: "#000", borderRadius: 14 }} />
|
||
<span style={{ fontFamily: f.m, fontSize: 11, color: c.dim }}>●●●</span>
|
||
</div>
|
||
<div style={{ flex: 1, overflow: "auto", minHeight: 0 }}>{render()}</div>
|
||
{showNav && <Nav active={scr} onChange={setScr} />}
|
||
</div>
|
||
</div>;
|
||
}
|