// ============== recruiter dashboard ==============
const APP_STATUSES = ["submitted", "reviewed", "shortlisted", "interviewing", "offered", "placed", "rejected", "withdrawn"];
const ROLE_STATUSES = ["draft", "open", "paused", "closed"];
function RecruiterDashboard({ go }) {
const { user, logout } = useAuth();
const [tab, setTab] = React.useState("overview");
const [inviteOpen, setInviteOpen] = React.useState(false);
return (
Recruiter · {user.email}
Control room.
{inviteOpen && setInviteOpen(false)} />}
{tab === "overview" &&
}
{tab === "roles" &&
}
{tab === "applications" &&
}
{tab === "candidates" &&
}
{tab === "companies" &&
}
);
}
function Overview() {
const [stats, setStats] = React.useState(null);
React.useEffect(() => {
window.W3J_API.apiFetch("/admin/dashboard").then(setStats).catch(() => setStats({}));
}, []);
if (!stats) return
Loading…
;
const cards = [
{ num: stats.open_roles ?? 0, label: "Open roles" },
{ num: stats.total_roles ?? 0, label: "Total roles" },
{ num: stats.candidates ?? 0, label: "Candidates" },
{ num: stats.applications_total ?? 0, label: "Applications" },
];
return (
Applications by status
{Object.entries(stats.applications_by_status || {}).map(([k, v]) => (
{k.replace(/_/g, " ")}
{v}
))}
);
}
// ---------- roles ----------
function AdminRoles() {
const [roles, setRoles] = React.useState([]);
const [companies, setCompanies] = React.useState([]);
const [editing, setEditing] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const reload = React.useCallback(async () => {
const [r, c] = await Promise.all([
window.W3J_API.apiFetch("/admin/roles"),
window.W3J_API.apiFetch("/admin/companies"),
]);
setRoles(r); setCompanies(c); setLoading(false);
}, []);
React.useEffect(() => { reload(); }, [reload]);
if (loading) return Loading…
;
return (
{roles.length} roles
{roles.map((r) => (
setEditing(r)}>
{r.public_id}
{r.title}
{r.company_display} · {r.remote}
{r.chain && {r.chain}}
{r.status}
${r.min_comp_k}–{r.max_comp_k}k
))}
{editing &&
setEditing(null)} onSaved={reload} />}
);
}
const ROLE_CATEGORIES = ["Protocol", "DeFi", "Trading", "Infrastructure", "Security", "DAO", "Gaming/NFT"];
const CHAINS = ["Solana", "EVM", "Bitcoin", "Multi-chain"];
const LEVELS = ["Mid", "Senior", "Lead", "Staff", "Director"];
function RoleEditor({ role, companies, onClose, onSaved }) {
const isNew = !role.id;
const [form, setForm] = React.useState({
public_id: role.public_id || "",
company_id: role.company_id || (companies[0]?.id || ""),
title: role.title || "",
category: role.category || ROLE_CATEGORIES[0],
chain: role.chain || "",
stack: (role.stack || []).join(", "),
type: role.type || "Full-time",
level: role.level || LEVELS[1],
remote: role.remote || "Remote · Global",
min_comp_k: role.min_comp_k ?? 0,
max_comp_k: role.max_comp_k ?? 0,
description: role.description || "",
requirements: (role.requirements || []).join("\n"),
offer: (role.offer || []).join("\n"),
status: role.status || "draft",
});
const [questions, setQuestions] = React.useState(role.questions || []);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState("");
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setBusy(true); setErr("");
try {
const body = {
...form,
chain: form.chain || null,
min_comp_k: Number(form.min_comp_k),
max_comp_k: Number(form.max_comp_k),
stack: form.stack.split(",").map((s) => s.trim()).filter(Boolean),
requirements: form.requirements.split("\n").map((s) => s.trim()).filter(Boolean),
offer: form.offer.split("\n").map((s) => s.trim()).filter(Boolean),
questions: questions.filter((q) => q.label?.trim()),
};
if (isNew) {
await window.W3J_API.apiFetch("/admin/roles", { method: "POST", body });
} else {
const { public_id, company_id, ...patch } = body;
await window.W3J_API.apiFetch(`/admin/roles/${role.id}`, { method: "PATCH", body: patch });
}
onSaved(); onClose();
} catch (ex) {
setErr(ex.message || "Save failed");
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!confirm("Delete this role? Applications will be deleted too.")) return;
await window.W3J_API.apiFetch(`/admin/roles/${role.id}`, { method: "DELETE" });
onSaved(); onClose();
};
return (
e.stopPropagation()}>
{isNew ? "New role" : `Edit ${role.public_id}`}
);
}
function QuestionsBuilder({ questions, onChange }) {
const add = () => onChange([...questions, {
id: "q_" + Math.random().toString(36).slice(2, 10),
label: "", type: "text", required: false,
}]);
const update = (i, patch) => onChange(questions.map((q, idx) => idx === i ? { ...q, ...patch } : q));
const remove = (i) => onChange(questions.filter((_, idx) => idx !== i));
return (
Custom application questions
Default fields (name, email, LinkedIn, X, location, expected comp, CV) are always shown to candidates.
Add any role-specific extras here.
{questions.length === 0 ? (
No custom questions.
) : (
questions.map((q, i) => (
update(i, { label: e.target.value })}
/>
))
)}
);
}
// ---------- applications ----------
function AdminApplications() {
const [apps, setApps] = React.useState([]);
const [roles, setRoles] = React.useState({});
const [statusFilter, setStatusFilter] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [expanded, setExpanded] = React.useState(null);
React.useEffect(() => {
(async () => {
const qs = statusFilter ? `?status=${statusFilter}` : "";
const a = await window.W3J_API.apiFetch(`/admin/applications${qs}`);
setApps(a);
const ids = [...new Set(a.map((x) => x.role_id))];
const fetched = await Promise.all(ids.map((id) => window.W3J_API.apiFetch(`/roles/${id}`).catch(() => null)));
const map = {};
fetched.forEach((r) => { if (r) map[r.id] = r; });
setRoles(map);
setLoading(false);
})();
}, [statusFilter]);
const updateStatus = async (id, status) => {
const updated = await window.W3J_API.apiFetch(`/admin/applications/${id}`, { method: "PATCH", body: { status } });
setApps(apps.map((a) => (a.id === id ? updated : a)));
};
if (loading) return Loading…
;
return (
{apps.length} applications
Applied
Role
Candidate
Status
{apps.map((a) => {
const r = roles[a.role_id];
const isOpen = expanded === a.id;
return (
{new Date(a.created_at).toLocaleDateString()}
{r?.title || "—"}
{r?.public_id} · {r?.company_display}
{a.full_name || "—"}
{a.email || a.candidate_id.slice(0, 8) + "…"}
{isOpen && }
);
})}
);
}
function ApplicationDetail({ app }) {
const fields = [
["Location", app.location],
["Expected comp", app.expected_comp],
["LinkedIn", app.linkedin_url],
["X / Twitter", app.twitter_url],
].filter(([, v]) => v);
const viewResume = async () => {
try {
const { download_url } = await window.W3J_API.apiFetch(`/uploads/application/${app.id}/resume/url`);
window.open(download_url, "_blank", "noopener");
} catch (ex) {
alert(ex.message || "Resume not available");
}
};
return (
Application detail
{fields.length > 0 && (
{fields.map(([k, v]) => (
{k}
{String(v).startsWith("http") ?
{v} : v}
))}
)}
{Object.entries(app.answers || {}).length > 0 && (
Custom question answers
{Object.entries(app.answers || {}).map(([qid, ans]) => (
))}
)}
);
}
function DownloadCsvBtn({ path, label }) {
const onClick = async () => {
const url = (window.W3J_API.API_BASE || "/api") + path;
const r = await fetch(url, { headers: { Authorization: `Bearer ${window.W3J_API.tokens.access}` } });
if (!r.ok) { alert("Export failed"); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = path.split("/").pop().replace(".csv", "") + ".csv";
a.click();
URL.revokeObjectURL(a.href);
};
return ;
}
// ---------- candidates ----------
function AdminCandidates() {
const [rows, setRows] = React.useState([]);
const [filter, setFilter] = React.useState("");
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
const qs = filter ? `?open_to_offers=${filter}` : "";
window.W3J_API.apiFetch(`/admin/candidates${qs}`).then((r) => { setRows(r); setLoading(false); });
}, [filter]);
if (loading) return Loading…
;
const viewResume = async (user_id) => {
try {
const { download_url } = await window.W3J_API.apiFetch(`/uploads/resume/${user_id}/url`);
window.open(download_url, "_blank", "noopener");
} catch (ex) {
alert(ex.message || "Resume not available");
}
};
return (
{rows.length} candidates
Name
Headline
Seniority
Chains
Status
{rows.map((r) => (
{r.full_name}
{r.headline || "—"}
{r.seniority || "—"}
{(r.chains || []).join(", ") || "—"}
{r.open_to_offers.replace(/_/g, " ")}
))}
);
}
// ---------- companies ----------
function AdminCompanies() {
const [rows, setRows] = React.useState([]);
const [editing, setEditing] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const reload = React.useCallback(() => {
window.W3J_API.apiFetch("/admin/companies").then((r) => { setRows(r); setLoading(false); });
}, []);
React.useEffect(() => { reload(); }, [reload]);
if (loading) return Loading…
;
return (
{rows.length} companies
Name
Public display
Category
Chain
{rows.map((c) => (
setEditing(c)}>
{c.name}
{c.public_display}
{c.category || "—"}
{c.chain || "—"}
))}
{editing &&
setEditing(null)} onSaved={reload} />}
);
}
function CompanyEditor({ company, onClose, onSaved }) {
const isNew = !company.id;
const [form, setForm] = React.useState({
name: company.name || "",
public_display: company.public_display || "",
category: company.category || "",
chain: company.chain || "",
description: company.description || "",
});
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState("");
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const submit = async (e) => {
e.preventDefault();
setBusy(true); setErr("");
try {
if (isNew) await window.W3J_API.apiFetch("/admin/companies", { method: "POST", body: form });
else await window.W3J_API.apiFetch(`/admin/companies/${company.id}`, { method: "PATCH", body: form });
onSaved(); onClose();
} catch (ex) {
setErr(ex.message);
} finally {
setBusy(false);
}
};
return (
);
}
function InviteModal({ onClose }) {
const [email, setEmail] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState("");
const [err, setErr] = React.useState("");
const submit = async (e) => {
e.preventDefault();
setBusy(true); setErr(""); setMsg("");
try {
await window.W3J_API.apiFetch("/auth/invite", { method: "POST", body: { email } });
setMsg(`Invite sent to ${email}. Check the email (or the API server logs in dev mode) for the accept link.`);
setEmail("");
} catch (ex) {
setErr(ex.message);
} finally { setBusy(false); }
};
return (
e.stopPropagation()}>
Invite recruiter
);
}
window.RecruiterDashboard = RecruiterDashboard;