EventBuzz_Console/src/app/components/global/GlobalStubPage.tsx

2556 lines
137 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from "react";
import {
Palette, Mic2, Users, FileText, BookOpen, MessageSquare,
Shield, Image, Layout, Tag, Mail,
Plus, Search, X, MoreHorizontal, Upload,
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
Link2, List, Check, Share2,
} from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext";
import { useAppNav } from "../../contexts/AppNavContext";
import { tagChipStyle } from "../participants/participantTagUtils";
import { useParticipantSelection } from "../participants/useParticipantSelection";
import { useParticipantBulkActions } from "../participants/useParticipantBulkActions";
import { ParticipantTagFilters } from "../participants/ParticipantTagFilters";
import { ParticipantSelectAllMatching } from "../participants/ParticipantSelectAllMatching";
import { ParticipantBulkActionBar } from "../participants/ParticipantBulkActionBar";
import { ParticipantBulkDialogs } from "../participants/ParticipantBulkDialogs";
import {
designTemplatesApi,
speakersApi,
participantsApi,
formsApi,
usersApi,
eventsApi,
participantListsApi,
listMedia,
contentCollectionsApi,
portfolioListOrganizationId,
eventMicrositesApi,
badgeBatchesApi,
} from "../../../services/api";
import type {
DesignTemplate,
DesignTemplateType,
BadgeBatchRecord,
Speaker,
ParticipantWithEvent,
FormWithSubmissionCount,
User,
ParticipantListRecord,
ListMemberRow,
Event,
LibraryContent,
ContentCollectionRecord,
EventMicrosite,
} from "../../../services/api";
import { LoadingState } from "../shared";
import { ConfirmModal } from "../shared";
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
import { ImportParticipantsPanel } from "../panels/ImportParticipantsPanel";
import { EditParticipantPanel } from "../panels/EditParticipantPanel";
import { CreateFormPanel } from "../panels/CreateFormPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
import { ShareFormPanel } from "../forms/ShareFormPanel";
import { usePermission } from "../../hooks/usePermission";
import { UploadFilePanel } from "../panels/UploadFilePanel";
import { CreateCollectionPanel } from "../panels/CreateCollectionPanel";
import { MessagingEmailPage } from "../messaging/MessagingEmailPage";
import { MessagingWhatsappPage } from "../messaging/MessagingWhatsappPage";
import { InviteUserPanel } from "../panels/InviteUserPanel";
import { CreateBrochurePanel } from "../panels/CreateBrochurePanel";
import {
fetchBrochurePdfContext,
buildBrochurePreviewData,
downloadBrochurePdfOffscreen,
BrochurePreviewModal,
type BrochurePdfContext,
} from "../brochure";
import { CreateMicrositePanel } from "../panels/CreateMicrositePanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { CreateEmailerPanel } from "../panels/CreateEmailerPanel";
import { ImportDesignPanel } from "../panels/ImportDesignPanel";
import { IS, Label, Field, RadioGroup, focusIn, focusOut } from "../panels/PanelShell";
/* ──────────────────────────────────────
Shared helpers
────────────────────────────────────── */
const labelStyle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
marginBottom: "4px",
};
/** Horizontally scrollable card for tables on small screens */
function ScrollTable({ minWidth, children }: { minWidth?: number; children: React.ReactNode }) {
return (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
<div style={{ overflowX: "auto" }}>
<div style={{ minWidth: minWidth ?? 500 }}>
{children}
</div>
</div>
</div>
);
}
function EmptyState({ icon, title, description, cta, onCta }: {
icon: React.ReactNode; title: string; description: string; cta?: string; onCta?: () => void;
}) {
return (
<div style={{ textAlign: "center", padding: "72px 20px", border: "2px dashed var(--border)", borderRadius: "var(--radius-card)" }}>
<div style={{ color: "var(--border)", marginBottom: "14px", display: "flex", justifyContent: "center" }}>{icon}</div>
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "6px" }}>{title}</p>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: cta ? "20px" : "0" }}>{description}</p>
{cta && onCta && (
<button onClick={onCta}
style={{ display: "inline-flex", alignItems: "center", gap: "6px", padding: "8px 18px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> {cta}
</button>
)}
</div>
);
}
/* ──────────────────────────────────────
Design pages (dynamic: brochure, microsite, badges, emailers)
────────────────────────────────────── */
const SUBPAGE_TO_TYPE: Record<string, DesignTemplateType> = {
"design-brochure": "brochure",
"design-microsite": "microsite",
"design-badges": "badge",
"design-emailers": "emailer",
};
const templateIcons: Record<string, React.ReactNode> = {
Brochure: <Image size={22} style={{ color: "var(--muted)" }} />,
"Microsite Templates": <Layout size={22} style={{ color: "var(--muted)" }} />,
Badges: <Tag size={22} style={{ color: "var(--muted)" }} />,
Emailers: <Mail size={22} style={{ color: "var(--muted)" }} />,
};
function formatLastEdited(updatedAt?: string): string {
if (!updatedAt) return "—";
const d = new Date(updatedAt);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000));
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} week(s) ago`;
if (days < 365) return `${Math.floor(days / 30)} month(s) ago`;
return d.toLocaleDateString();
}
export function DesignPage({ subPage }: { subPage?: string }) {
const { user } = useAuth();
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const activeTab = subPage === "design-brochure" ? "Brochure"
: subPage === "design-microsite" ? "Microsite Templates"
: subPage === "design-badges" ? "Badges"
: subPage === "design-emailers" ? "Emailers"
: "Brochure";
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
const isSuperAdmin = user?.role === "super_admin";
const canManageMicrosite =
apiType === "microsite" &&
user &&
["super_admin", "admin", "moderator"].includes(user.role);
const canManageBadgeBatch = Boolean(user && ["super_admin", "admin", "moderator"].includes(user.role));
const [templates, setTemplates] = useState<DesignTemplate[]>([]);
const [badgeBatches, setBadgeBatches] = useState<BadgeBatchRecord[]>([]);
const [badgeBatchesRefreshing, setBadgeBatchesRefreshing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [microEvents, setMicroEvents] = useState<Event[]>([]);
const [orgMicrosites, setOrgMicrosites] = useState<EventMicrosite[]>([]);
const [microLoading, setMicroLoading] = useState(false);
const [microError, setMicroError] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
const [showBrochure, setShowBrochure] = useState(false);
const [showMicrosite, setShowMicrosite] = useState(false);
const [showBadge, setShowBadge] = useState(false);
const [duplicateBadgeBatchId, setDuplicateBadgeBatchId] = useState<string | null>(null);
const [showEmailer, setShowEmailer] = useState(false);
const [showImport, setShowImport] = useState(false);
const [editTemplateId, setEditTemplateId] = useState<string | null>(null);
const [editMicrositeEventId, setEditMicrositeEventId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<DesignTemplate | null>(null);
const [brochurePdfBusyId, setBrochurePdfBusyId] = useState<string | null>(null);
const [brochurePdfError, setBrochurePdfError] = useState<string | null>(null);
const [brochurePreviewCtx, setBrochurePreviewCtx] = useState<BrochurePdfContext | null>(null);
const [emailerPreview, setEmailerPreview] = useState<{ html: string; title: string } | null>(null);
const [emailerPreviewBusyId, setEmailerPreviewBusyId] = useState<string | null>(null);
const fetchDesignList = useCallback((options?: { badgeSoftRefresh?: boolean }) => {
const badgeSoftRefresh = options?.badgeSoftRefresh === true;
if (apiType === "microsite") {
setLoading(false);
setTemplates([]);
setMicroLoading(true);
setMicroError(null);
setOrgMicrosites([]);
const orgId = portfolioListOrganizationId(user);
if (!orgId) {
setMicroEvents([]);
setMicroError(
isSuperAdmin
? "Assign an organisation to your account to load events."
: "Your profile has no organisation.",
);
setMicroLoading(false);
return;
}
Promise.all([
eventsApi.list({ limit: 100, organization: orgId }),
eventMicrositesApi.listForOrganization(orgId),
])
.then(([evRes, msRes]) => {
if (evRes.data?.events != null) {
setMicroEvents(evRes.data.events);
} else {
setMicroEvents([]);
setMicroError(evRes.error || "Failed to load events");
}
if (msRes.error || !msRes.data) {
setOrgMicrosites([]);
} else {
setOrgMicrosites(msRes.data);
}
})
.catch(() => {
setMicroEvents([]);
setOrgMicrosites([]);
setMicroError("Failed to load events");
})
.finally(() => setMicroLoading(false));
return;
}
if (apiType === "badge") {
setMicroLoading(false);
if (badgeSoftRefresh) setBadgeBatchesRefreshing(true);
else setLoading(true);
setError(null);
setTemplates([]);
const orgId = portfolioListOrganizationId(user);
if (!orgId) {
setBadgeBatches([]);
setError(
isSuperAdmin
? "Assign an organisation to your account to load badge exports."
: "Your profile has no organisation.",
);
if (badgeSoftRefresh) setBadgeBatchesRefreshing(false);
else setLoading(false);
return;
}
badgeBatchesApi
.list({ organization: orgId, limit: 100 })
.then((res) => {
if (res.data?.batches != null) setBadgeBatches(res.data.batches);
else setError(res.error || "Failed to load badge exports");
})
.catch(() => setError("Failed to load badge exports"))
.finally(() => {
if (badgeSoftRefresh) setBadgeBatchesRefreshing(false);
else setLoading(false);
});
return;
}
setMicroLoading(false);
setLoading(true);
setError(null);
const orgId = portfolioListOrganizationId(user);
if (!orgId) {
setTemplates([]);
setError(
isSuperAdmin
? "Assign an organisation to your account (or use an org-scoped user) to load design templates."
: "Your profile has no organisation.",
);
setLoading(false);
return;
}
designTemplatesApi
.list({ type: apiType, limit: 100, organization: orgId })
.then((res) => {
if (res.data?.templates != null) setTemplates(res.data.templates);
else setError(res.error || "Failed to load templates");
})
.catch(() => setError("Failed to load templates"))
.finally(() => setLoading(false));
}, [apiType, user, isSuperAdmin]);
useEffect(() => {
fetchDesignList();
}, [fetchDesignList]);
const handleCreate = () => {
setEditTemplateId(null);
setEditMicrositeEventId(null);
if (activeTab === "Brochure") setShowBrochure(true);
else if (activeTab === "Microsite Templates") setShowMicrosite(true);
else if (activeTab === "Badges") {
setDuplicateBadgeBatchId(null);
setShowBadge(true);
}
else if (activeTab === "Emailers") setShowEmailer(true);
};
const handleEdit = (t: DesignTemplate) => {
setEditTemplateId(t._id);
if (apiType === "brochure") setShowBrochure(true);
else if (apiType === "emailer") setShowEmailer(true);
};
const handleOpenMicrositeForEvent = (ev: Event) => {
setEditMicrositeEventId(ev._id);
setShowMicrosite(true);
};
const handleClosePanel = (panel: "brochure" | "microsite" | "badge" | "emailer") => {
setEditTemplateId(null);
setEditMicrositeEventId(null);
if (panel === "brochure") setShowBrochure(false);
else if (panel === "microsite") setShowMicrosite(false);
else if (panel === "badge") {
setShowBadge(false);
setDuplicateBadgeBatchId(null);
}
else if (panel === "emailer") setShowEmailer(false);
fetchDesignList();
};
const handleDeleteConfirm = async () => {
if (!deleteConfirm) return;
const id = deleteConfirm._id;
setDeleteConfirm(null);
const res = await designTemplatesApi.remove(id);
if (!res.error) fetchDesignList();
};
const handleBrochurePdf = async (t: DesignTemplate, mode: "preview" | "download") => {
if (apiType !== "brochure") return;
setBrochurePdfBusyId(t._id);
setBrochurePdfError(null);
try {
const fresh = await designTemplatesApi.get(t._id);
if (fresh.error || !fresh.data) throw new Error(fresh.error || "Failed to load brochure");
const ctxRes = await fetchBrochurePdfContext(fresh.data);
if (!ctxRes.ok) throw new Error(ctxRes.error);
const safeName =
(fresh.data.name || "brochure").replace(/[^a-z0-9-_]+/gi, "-").replace(/^-|-$/g, "") || "brochure";
if (mode === "preview") {
setBrochurePreviewCtx(ctxRes.ctx);
} else {
const data = buildBrochurePreviewData(ctxRes.ctx);
const templateId = ctxRes.ctx.content.layoutId || "tpl1";
await downloadBrochurePdfOffscreen(templateId, data, `${safeName}.pdf`);
}
} catch (e) {
setBrochurePdfError(e instanceof Error ? e.message : "Could not generate PDF");
} finally {
setBrochurePdfBusyId(null);
}
};
const handleEmailerPreview = async (t: DesignTemplate) => {
setEmailerPreviewBusyId(t._id);
try {
const fresh = await designTemplatesApi.get(t._id);
if (fresh.error || !fresh.data) return;
const c = fresh.data.content;
let html = "";
if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") {
html = String((c as { html: string }).html || "");
}
if (!html.trim()) {
html =
'<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>';
}
setEmailerPreview({ html, title: fresh.data.name || "Email preview" });
} finally {
setEmailerPreviewBusyId(null);
}
};
const createLabel = activeTab === "Brochure" ? "Create Brochure"
: activeTab === "Badges" ? "Create Badge"
: activeTab === "Emailers" ? "Create Emailer"
: activeTab === "Microsite Templates" ? "Configure microsite"
: "Create Template";
const showDesignHeaderActions =
apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin;
function badgeBatchEventLabel(b: BadgeBatchRecord): string {
const e = b.event;
if (e && typeof e === "object" && "name" in e) return (e as { name?: string }).name || "—";
return "—";
}
const badgeListOrgId = portfolioListOrganizationId(user);
return (
<>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{ display: "flex", alignItems: isMobile ? "stretch" : "flex-start", flexDirection: isMobile ? "column" : "row", justifyContent: "space-between", gap: "12px" }}>
<div>
<p style={labelStyle}>Portfolio · Design</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: isMobile ? "var(--text-xl)" : "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{activeTab}</h1>
{!loading && !error && apiType !== "microsite" && apiType !== "badge" && (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{templates.length} template{templates.length !== 1 ? "s" : ""}
</p>
)}
{!loading && !error && apiType === "badge" && (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{badgeBatches.length} export job{badgeBatches.length !== 1 ? "s" : ""}
</p>
)}
{!microLoading && !microError && apiType === "microsite" && (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{microEvents.length} event{microEvents.length !== 1 ? "s" : ""}
{" · "}
{orgMicrosites.length} microsite template{orgMicrosites.length !== 1 ? "s" : ""}
</p>
)}
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap", justifyContent: isMobile ? "stretch" : "flex-end" }}>
{apiType === "badge" && badgeListOrgId && (
<button
type="button"
disabled={loading || badgeBatchesRefreshing}
onClick={() => void fetchDesignList({ badgeSoftRefresh: true })}
style={{
display: "flex",
alignItems: "center",
gap: "5px",
padding: "7px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--card-foreground)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
cursor: loading || badgeBatchesRefreshing ? "wait" : "pointer",
opacity: loading || badgeBatchesRefreshing ? 0.65 : 1,
flex: isMobile ? 1 : "none",
justifyContent: isMobile ? "center" : "flex-start",
}}
>
<RefreshCw size={13} />
{badgeBatchesRefreshing ? "Refreshing…" : "Refresh"}
</button>
)}
{showDesignHeaderActions && (
<>
{isSuperAdmin && activeTab !== "Emailers" && activeTab !== "Badges" && (
<button
onClick={() => setShowImport(true)}
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = "0.88"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
>
<Upload size={13} /> Import
</button>
)}
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && (
<button
onClick={handleCreate}
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = "0.88"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
>
<Plus size={13} /> {isMobile ? (activeTab === "Brochure" ? "Brochure" : activeTab === "Badges" ? "Badge" : activeTab === "Emailers" ? "Emailer" : activeTab === "Microsite Templates" ? "Microsite" : "Template") : createLabel}
</button>
)}
</>
)}
</div>
</div>
{loading && apiType !== "microsite" && (
<LoadingState message={apiType === "badge" ? "Loading badge exports…" : "Loading templates…"} />
)}
{microLoading && apiType === "microsite" && <LoadingState message="Loading events…" />}
{error && apiType !== "microsite" && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{error}</p>}
{brochurePdfError && apiType === "brochure" && (
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>
{brochurePdfError}{" "}
<button
type="button"
onClick={() => setBrochurePdfError(null)}
style={{ marginLeft: "8px", fontSize: "var(--text-xs)", textDecoration: "underline", background: "none", border: "none", cursor: "pointer", color: "inherit" }}
>
Dismiss
</button>
</p>
)}
{microError && apiType === "microsite" && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{microError}</p>}
{!loading && !error && apiType !== "microsite" && apiType !== "badge" && (
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(auto-fill, minmax(200px, 1fr))", gap: isMobile ? "10px" : "14px" }}>
{templates.map(t => (
<div key={t._id}
onMouseEnter={() => setHovered(t._id)}
onMouseLeave={() => setHovered(null)}
style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden", cursor: "pointer", boxShadow: hovered === t._id ? "var(--elevation-sm)" : "none", transition: "box-shadow 0.15s" }}
>
<div style={{ height: "120px", background: "var(--secondary)", display: "flex", alignItems: "center", justifyContent: "center", borderBottom: "1px solid var(--border)", position: "relative" }}>
{templateIcons[activeTab]}
{hovered === t._id && (
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
{isSuperAdmin && (
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button>
)}
{apiType === "brochure" ? (
<>
<button
type="button"
disabled={brochurePdfBusyId === t._id}
onClick={e => { e.stopPropagation(); void handleBrochurePdf(t, "preview"); }}
style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--card)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: brochurePdfBusyId === t._id ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", opacity: brochurePdfBusyId === t._id ? 0.7 : 1 }}
>
{brochurePdfBusyId === t._id ? "…" : "Preview"}
</button>
<button
type="button"
disabled={brochurePdfBusyId === t._id}
onClick={e => { e.stopPropagation(); void handleBrochurePdf(t, "download"); }}
style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--card)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: brochurePdfBusyId === t._id ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", opacity: brochurePdfBusyId === t._id ? 0.7 : 1 }}
>
<Download size={12} /> {brochurePdfBusyId === t._id ? "…" : "PDF"}
</button>
</>
) : apiType === "emailer" ? (
<button
type="button"
disabled={emailerPreviewBusyId === t._id}
onClick={(e) => {
e.stopPropagation();
void handleEmailerPreview(t);
}}
style={{
padding: "5px 12px",
borderRadius: "var(--radius-button)",
background: "var(--card)",
color: "var(--foreground)",
border: "1px solid var(--border)",
cursor: emailerPreviewBusyId === t._id ? "wait" : "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
opacity: emailerPreviewBusyId === t._id ? 0.7 : 1,
}}
>
{emailerPreviewBusyId === t._id ? "…" : "Preview"}
</button>
) : null}
{isSuperAdmin && (
<button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button>
)}
</div>
)}
</div>
<div style={{ padding: "10px 12px" }}>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span>
</div>
</div>
</div>
))}
{isSuperAdmin && (
<div
onClick={handleCreate}
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<Plus size={24} style={{ color: "var(--muted)" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{createLabel}</p>
</div>
)}
</div>
)}
{!loading && !error && apiType === "badge" && (
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(260px, 1fr))", gap: isMobile ? "10px" : "14px" }}>
{badgeBatches.map((b) => (
<div
key={b._id}
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
overflow: "hidden",
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Tag size={20} style={{ color: "var(--muted)", flexShrink: 0 }} />
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{b.name}</p>
</div>
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{badgeBatchEventLabel(b)}</span>
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Status: <strong style={{ color: "var(--foreground)" }}>{b.status}</strong>
{b.itemCount != null ? ` · ${b.itemCount} badge${b.itemCount !== 1 ? "s" : ""}` : ""}
</span>
{b.lastError && b.status === "failed" && (
<span style={{ fontSize: "10px", color: "var(--destructive)" }}>{b.lastError}</span>
)}
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px", marginTop: "4px" }}>
{canManageBadgeBatch && (
<button
type="button"
title="Duplicate — copy settings and edit"
onClick={() => {
setDuplicateBadgeBatchId(b._id);
setShowBadge(true);
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: "pointer",
}}
>
<Copy size={13} /> Duplicate
</button>
)}
{b.status === "ready" && b.zipUrl ? (
<a
href={b.zipUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
textDecoration: "none",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
<Download size={13} /> Download zip
</a>
) : (
<button
type="button"
disabled
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
color: "var(--muted)",
border: "1px solid var(--border)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "not-allowed",
}}
>
<Download size={13} /> Download zip
</button>
)}
</div>
{b.createdAt && (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Requested {formatLastEdited(b.createdAt)}</span>
)}
</div>
))}
{canManageBadgeBatch && (
<div
onClick={handleCreate}
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<Plus size={24} style={{ color: "var(--muted)" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{createLabel}</p>
</div>
)}
</div>
)}
{!microLoading && !microError && apiType === "microsite" && (
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(240px, 1fr))", gap: isMobile ? "10px" : "14px" }}>
{microEvents.map((ev) => {
const forEvent = orgMicrosites.filter((m) => String(m.event) === String(ev._id));
const n = forEvent.length;
const folders = forEvent.map((m) => (m.publishFolder || "").trim()).filter(Boolean);
const folderLabel =
folders.length === 0 ? "—" : folders.length <= 2 ? folders.join(", ") : `${folders.slice(0, 2).join(", ")} +${folders.length - 2}`;
const liveCount = forEvent.filter((m) => m.enabled).length;
const statusLabel =
n === 0 ? "" : liveCount === n ? " · all live" : liveCount === 0 ? " · all draft" : ` · ${liveCount} live, ${n - liveCount} draft`;
return (
<div
key={ev._id}
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
overflow: "hidden",
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{ev.name}</p>
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
{n === 0
? "0 microsite templates"
: `${n} microsite template${n !== 1 ? "s" : ""} · slug(s): ${folderLabel}${statusLabel}`}
</span>
{canManageMicrosite && (
<button
type="button"
onClick={() => handleOpenMicrositeForEvent(ev)}
style={{
padding: "6px 12px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
alignSelf: "flex-start",
}}
>
Configure microsite
</button>
)}
</div>
);
})}
{canManageMicrosite && (
<div
onClick={handleCreate}
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "120px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.background = "transparent"; }}
>
<Plus size={24} style={{ color: "var(--muted)" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{createLabel}</p>
</div>
)}
</div>
)}
<Footer />
</div>
{showBrochure && <CreateBrochurePanel templateId={editTemplateId ?? undefined} onClose={() => handleClosePanel("brochure")} onSuccess={fetchDesignList} />}
{brochurePreviewCtx && (
<BrochurePreviewModal
ctx={brochurePreviewCtx}
onClose={() => setBrochurePreviewCtx(null)}
/>
)}
{emailerPreview && (
<div
role="presentation"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.55)",
zIndex: 400,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
onClick={() => setEmailerPreview(null)}
>
<div
role="dialog"
aria-label="Email preview"
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "100%",
maxWidth: 720,
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
border: "1px solid var(--border)",
boxShadow: "0 20px 40px rgba(0,0,0,0.2)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
}}
>
<span style={{ fontWeight: 600, fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>{emailerPreview.title}</span>
<button type="button" aria-label="Close" onClick={() => setEmailerPreview(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--foreground)" }}>
<X size={18} />
</button>
</div>
<iframe title="Email preview" srcDoc={emailerPreview.html} sandbox="allow-same-origin" style={{ flex: 1, minHeight: 420, border: "none", width: "100%" }} />
</div>
</div>
)}
{showMicrosite && (
<CreateMicrositePanel
initialEventId={editMicrositeEventId ?? undefined}
onClose={() => handleClosePanel("microsite")}
onSuccess={fetchDesignList}
/>
)}
{showBadge && (
<CreateBadgePanel
key={duplicateBadgeBatchId ?? "create"}
duplicateFromBatchId={duplicateBadgeBatchId}
onClose={() => handleClosePanel("badge")}
onSuccess={fetchDesignList}
/>
)}
{showEmailer && <CreateEmailerPanel templateId={editTemplateId ?? undefined} onClose={() => handleClosePanel("emailer")} onSuccess={fetchDesignList} />}
{showImport && <ImportDesignPanel onClose={() => setShowImport(false)} defaultType={
activeTab === "Brochure" ? "Brochure"
: activeTab === "Microsite Templates" ? "Microsite Template"
: activeTab === "Badges" ? "Badge"
: activeTab === "Emailers" ? "Emailer"
: "Other"
} />}
{deleteConfirm && (
<ConfirmModal
title="Delete template?"
message={`Delete "${deleteConfirm.name}"? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
variant="danger"
/>
)}
</>
);
}
/* ──────────────────────────────────────
Faculty & Speakers (dynamic)
────────────────────────────────────── */
const AVATAR_COLORS = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#be185d", "#0d9488", "#b45309"];
function avatarColor(id: string): string {
let n = 0;
for (let i = 0; i < id.length; i++) n += id.charCodeAt(i);
return AVATAR_COLORS[n % AVATAR_COLORS.length];
}
export function FacultyPage() {
const { user } = useAuth();
const { selectedEventId } = useAppNav();
const [speakers, setSpeakers] = useState<Speaker[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [hovered, setHovered] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const [showAddSpeaker, setShowAddSpeaker] = useState(false);
const [editSpeakerId, setEditSpeakerId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Speaker | null>(null);
const isSuperAdmin = user?.role === "super_admin";
const userOrgId =
user?.organization && typeof user.organization === "object" && "_id" in user.organization
? String((user.organization as { _id: string })._id)
: user?.organization
? String(user.organization)
: undefined;
const fetchSpeakers = useCallback(() => {
setLoading(true);
setError(null);
if (!userOrgId) {
setError(
isSuperAdmin
? "Your profile has no organisation — assign one in settings or add ?organization= to the API. Faculty list is scoped by organisation."
: "Your profile has no organisation.",
);
setSpeakers([]);
setLoading(false);
return;
}
speakersApi.list({ limit: 200, organization: userOrgId })
.then(res => {
if (res.data?.speakers != null) setSpeakers(res.data.speakers);
else setError(res.error ?? "Failed to load speakers");
})
.catch(() => setError("Failed to load speakers"))
.finally(() => setLoading(false));
}, [isSuperAdmin, userOrgId]);
useEffect(() => { fetchSpeakers(); }, [fetchSpeakers]);
const filtered = speakers.filter(s => {
const name = `${s.firstName} ${s.lastName}`.toLowerCase();
const title = (s.title ?? "").toLowerCase();
const org = (s.organizationName ?? "").toLowerCase();
const q = search.toLowerCase();
return !q || name.includes(q) || title.includes(q) || org.includes(q);
});
const handleEdit = (s: Speaker) => {
setEditSpeakerId(s._id);
setShowAddSpeaker(true);
};
const handleClosePanel = () => {
setEditSpeakerId(null);
setShowAddSpeaker(false);
fetchSpeakers();
};
const handleDeleteConfirm = async () => {
if (!deleteConfirm) return;
const id = deleteConfirm._id;
setDeleteConfirm(null);
const res = await speakersApi.remove(id);
if (!res.error) fetchSpeakers();
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div>
<p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Faculty &amp; Speakers</h1>
{!loading && !error && <p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{speakers.length} speaker{speakers.length !== 1 ? "s" : ""} across all events</p>}
</div>
{isSuperAdmin && (
<button
onClick={() => { setEditSpeakerId(null); setShowAddSpeaker(true); }}
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Add Speaker
</button>
)}
</div>
{/* Search */}
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "0 10px", height: "36px", maxWidth: "320px", background: "var(--card)", border: `1px solid ${focused ? "var(--ring)" : "var(--border)"}`, borderRadius: "var(--radius-button)", transition: "border-color 0.12s" }}>
<Search size={13} style={{ color: "var(--muted)", flexShrink: 0 }} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search speakers…"
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)" }} />
{search && <button onClick={() => setSearch("")} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: 0 }}><X size={12} /></button>}
</div>
{loading && <LoadingState message="Loading speakers…" />}
{error && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{error}</p>}
{!loading && !error && (
<ScrollTable>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 80px 60px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
{["Speaker", "Title", "Organisation", "Sessions", ""].map(h => (
<span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>
))}
</div>
{filtered.map(s => {
const name = `${s.firstName} ${s.lastName}`.trim() || "—";
const sessionsCount = s.sessions?.length ?? 0;
const initials = name.split(/\s+/).map(n => n[0]).join("").slice(0, 2).toUpperCase() || "?";
return (
<div key={s._id}
onMouseEnter={() => setHovered(s._id)}
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 80px 60px", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === s._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div style={{ width: "32px", height: "32px", borderRadius: "50%", background: avatarColor(s._id), display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span>
</div>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{name}</span>
</div>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{s.title ?? "—"}</span>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{s.organizationName ?? "—"}</span>
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: "var(--accent)", color: "var(--primary)", fontSize: "11px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{sessionsCount} {sessionsCount === 1 ? "session" : "sessions"}</span>
<div style={{ display: "flex", gap: "4px", opacity: hovered === s._id ? 1 : 0.3, transition: "opacity 0.15s" }}>
{isSuperAdmin && (
<>
<button title="Edit speaker" onClick={() => handleEdit(s)} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Pencil size={11} />
</button>
<button title="Delete speaker" onClick={() => setDeleteConfirm(s)} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Trash2 size={11} />
</button>
</>
)}
</div>
</div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: "48px 20px", textAlign: "center" }}>
<Search size={24} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>No speakers found</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>{search ? `No results for "${search}". Try a different name or title.` : "Add a speaker to get started."}</p>
{search && (
<button onClick={() => setSearch("")}
style={{ display: "inline-flex", alignItems: "center", gap: "5px", padding: "5px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", color: "var(--card-foreground)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
<X size={11} /> Clear search
</button>
)}
</div>
)}
</ScrollTable>
)}
<Footer />
{showAddSpeaker && (
<AddSpeakerPanel
speakerId={editSpeakerId ?? undefined}
eventId={selectedEventId}
onClose={handleClosePanel}
onSuccess={fetchSpeakers}
/>
)}
{deleteConfirm && (
<ConfirmModal
title="Delete speaker?"
message={`Remove "${deleteConfirm.firstName} ${deleteConfirm.lastName}"? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
variant="danger"
/>
)}
</div>
);
}
/* ──────────────────────────────────────
All Participants (Global)
────────────────────────────────────── */
function toAllParticipantRow(p: ParticipantWithEvent) {
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
const statusRaw = p.status;
const statusLabel =
statusRaw == null || statusRaw === ""
? "—"
: statusRaw === "cancelled"
? "Cancelled"
: statusRaw === "checked_in"
? "Checked In"
: statusRaw === "confirmed"
? "Confirmed"
: "Pending";
const ev = p.event as { _id?: string; name?: string } | undefined;
const eventName = ev?.name ?? "—";
const id = String(p._id);
return { rowKey: id, id, name, email: p.email ?? "", tags: p.tags ?? [], eventName, status: statusLabel };
}
export function AllParticipantsPage() {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const isSuperAdmin = user?.role === "super_admin";
const { setActiveNav } = useAppNav();
const [list, setList] = useState<ReturnType<typeof toAllParticipantRow>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [limit] = useState(50);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [activeTag, setActiveTag] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const [loading, setLoading] = useState(true);
const [showCustomList, setShowCustomList] = useState(false);
const [exporting, setExporting] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importMsg, setImportMsg] = useState<string | null>(null);
const [listNonce, setListNonce] = useState(0);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const {
selectedIds,
setSelectedIds,
selectAllMatchingActive,
setSelectAllMatchingActive,
headerSelectRef,
toggleRow,
toggleAllVisible,
clearSelection,
} = useParticipantSelection(
list.map((p) => p.id),
[search, activeTag]
);
const refreshList = useCallback(() => setListNonce((n) => n + 1), []);
const bulk = useParticipantBulkActions({
selectedIds,
listOrgId,
onMessage: setImportMsg,
onClearSelection: clearSelection,
onRefresh: refreshList,
});
useEffect(() => {
searchDebounce.current = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchInput]);
useEffect(() => { setPage(1); }, [search, activeTag]);
useEffect(() => {
if (!listOrgId) {
setTagOptions([]);
return;
}
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
if (r.data?.tags?.length) setTagOptions(r.data.tags);
});
}, [listOrgId, listNonce]);
useEffect(() => {
if (!listOrgId) {
setList([]);
setTotal(0);
setLoading(false);
return;
}
setLoading(true);
participantsApi
.listAll({
organization: listOrgId,
page,
limit,
q: search || undefined,
tag: activeTag ?? undefined,
})
.then((res) => {
setLoading(false);
if (res.data?.participants) {
setList((res.data.participants as ParticipantWithEvent[]).map(toAllParticipantRow));
setTotal(res.data.total ?? 0);
}
})
.catch(() => setLoading(false));
}, [listOrgId, page, limit, search, activeTag, listNonce]);
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
const runExportAll = async () => {
if (exporting) return;
setExporting(true);
if (!listOrgId) return;
await participantsApi.downloadExportAll({
organization: listOrgId,
q: search || undefined,
tag: activeTag ?? undefined,
});
setExporting(false);
};
const selectAllMatchingSearch = async () => {
if (!listOrgId) {
setImportMsg(isSuperAdmin ? "Organisation required for this action." : "Your profile has no organisation.");
setSelectAllMatchingActive(false);
return;
}
const r = await participantsApi.matchingIds({
organization: listOrgId,
q: search || undefined,
tag: activeTag ?? undefined,
});
if (r.error) {
setImportMsg(r.error);
setSelectAllMatchingActive(false);
return;
}
const ids = r.data?.ids ?? [];
if (r.data?.capped) setImportMsg(`Note: only the first ${ids.length} matching people are selected (limit). Refine filters if needed.`);
setSelectedIds(ids);
setSelectAllMatchingActive(ids.length > 0);
};
const confirmDeleteOne = async () => {
if (!deleteTarget) return;
const { id, name } = deleteTarget;
setDeleteTarget(null);
const res = await participantsApi.deleteOne(id);
if (res.error) {
setImportMsg(res.error);
return;
}
setImportMsg(`Removed ${name}.`);
setSelectAllMatchingActive(false);
setSelectedIds((prev) => prev.filter((x) => x !== id));
setListNonce((n) => n + 1);
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{importMsg && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<span>{importMsg}</span>
<button type="button" onClick={() => setImportMsg(null)} style={{ background: "none", border: "none", cursor: "pointer" }}><X size={16} /></button>
</div>
)}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: "12px" }}>
<div>
<p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))}
</p>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
<button type="button" disabled={exporting} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting ? 0.7 : 1 }}>
<Download size={13} /> {exporting ? "Exporting…" : "Export"}
</button>
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
<Upload size={13} /> Import
</button>
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
Custom lists
</button>
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Create Custom List
</button>
</div>
</div>
{/* Filter row */}
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<ParticipantSelectAllMatching
total={total}
loading={loading}
busy={bulk.bulkBusy}
checked={selectAllMatchingActive}
onChange={(checked) => {
if (checked) void selectAllMatchingSearch();
else clearSelection();
}}
/>
{/* Search */}
<div style={{ display: "flex", alignItems: "center", gap: "7px", padding: "0 10px", height: "34px", background: "var(--card)", border: `1px solid ${focused ? "var(--ring)" : "var(--border)"}`, borderRadius: "var(--radius-button)", maxWidth: "280px", flex: "1 1 200px", transition: "border-color 0.12s" }}>
<Search size={12} style={{ color: "var(--muted)", flexShrink: 0 }} />
<input value={searchInput} onChange={e => setSearchInput(e.target.value)} placeholder="Search participants…"
onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}
style={{ flex: 1, background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)" }} />
{searchInput && <button type="button" onClick={() => setSearchInput("")} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: 0 }}><X size={11} /></button>}
</div>
{/* Tag filters */}
{tagOptions.length === 0 ? (
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>No tags yet — add tags when editing or importing.</span>
) : (
<ParticipantTagFilters tagOptions={tagOptions} activeTag={activeTag} onChange={setActiveTag} />
)}
</div>
<ParticipantBulkActionBar
selectedCount={selectedIds.length}
busy={bulk.bulkBusy}
variant="portfolio"
onAddTags={() => void bulk.openBulkDialog("addTags")}
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
onAssignEvents={() => void bulk.openBulkDialog("assignEvents")}
onNewList={() => void bulk.openBulkDialog("newList")}
onAddToList={() => void bulk.openBulkDialog("addToList")}
onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }}
onClearSelection={clearSelection}
/>
{/* Table */}
<ScrollTable minWidth={720}>
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Event</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Actions</span>
</div>
{loading ? (
<div style={{ padding: "40px", textAlign: "center", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Loading…</div>
) : list.length === 0 ? (
<div style={{ padding: "40px", textAlign: "center" }}>
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
No participants found
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
{(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
</p>
{(search || activeTag) && (
<button type="button" onClick={clearFilters}
style={{ display: "inline-flex", alignItems: "center", gap: "5px", padding: "5px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", color: "var(--card-foreground)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>
<X size={11} /> Clear filters
</button>
)}
</div>
) : list.map(p => (
<div key={p.rowKey}
onMouseEnter={() => setHovered(p.rowKey)}
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
>
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
<div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
</div>
<div style={{ minWidth: 0 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
</div>
</div>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span>
<div style={{ display: "flex", gap: "3px", flexWrap: "wrap" }}>
{p.tags.map(tag => {
const tc = tagChipStyle(tag);
return <span key={tag} style={{ padding: "1px 6px", borderRadius: "999px", background: tc.bg, color: tc.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{tag}</span>;
})}
{p.tags.length === 0 && <span style={{ fontSize: "10px", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>—</span>}
</div>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
<Pencil size={13} />
</button>
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
<Trash2 size={13} />
</button>
</div>
</div>
))}
</ScrollTable>
{!loading && total > limit && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", padding: "8px 0" }}>
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: page <= 1 ? "not-allowed" : "pointer",
opacity: page <= 1 ? 0.5 : 1,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
}}
>
<ChevronLeft size={16} /> Previous
</button>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
{page} / {Math.max(1, Math.ceil(total / limit))}
</span>
<button
type="button"
disabled={page >= Math.ceil(total / limit)}
onClick={() => setPage((p) => p + 1)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: page >= Math.ceil(total / limit) ? "not-allowed" : "pointer",
opacity: page >= Math.ceil(total / limit) ? 0.5 : 1,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
}}
>
Next <ChevronRight size={16} />
</button>
</div>
)}
{editParticipantId && (
<EditParticipantPanel
participantId={editParticipantId}
onClose={() => setEditParticipantId(null)}
onSaved={() => {
setListNonce((n) => n + 1);
if (!listOrgId) return;
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
if (r.data?.tags?.length) setTagOptions(r.data.tags);
});
}}
/>
)}
{deleteTarget && (
<ConfirmModal
title="Delete participant?"
message={`Remove ${deleteTarget.name} from your organization? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={() => void confirmDeleteOne()}
onCancel={() => setDeleteTarget(null)}
variant="danger"
/>
)}
<ParticipantBulkDialogs
bulkDialog={bulk.bulkDialog}
bulkBusy={bulk.bulkBusy}
selectedCount={selectedIds.length}
selectedTagValues={bulk.selectedTagValues}
onToggleTag={bulk.toggleSelectedTag}
availableTags={tagOptions}
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
newListName={bulk.newListName}
onNewListNameChange={bulk.setNewListName}
newListDesc={bulk.newListDesc}
onNewListDescChange={bulk.setNewListDesc}
eventsPick={bulk.eventsPick}
eventPickIds={bulk.eventPickIds}
onToggleEventPick={bulk.toggleEventPick}
fixedLists={bulk.fixedLists}
addToListId={bulk.addToListId}
onAddToListIdChange={bulk.setAddToListId}
onClose={() => bulk.setBulkDialog(null)}
onApplyTags={(action, overrideTags) => void bulk.runBulkTags(action, overrideTags)}
onAssignEvents={() => void bulk.runBulkAssignEvents()}
onCreateList={() => void bulk.runBulkNewList()}
onAddToList={() => void bulk.runBulkAddToList()}
/>
<Footer />
{showImport && (
<ImportParticipantsPanel
onClose={() => setShowImport(false)}
onSuccess={(msg) => {
setImportMsg(msg);
setListNonce((n) => n + 1);
}}
/>
)}
{showCustomList && (
<CreateCustomListPanel
onClose={() => setShowCustomList(false)}
onCreated={() => setListNonce((n) => n + 1)}
/>
)}
</div>
);
}
function formatListMemberStatus(s: string | undefined): string {
if (s == null || s === "") return "—";
const raw = String(s).toLowerCase();
if (raw === "cancelled" || raw === "canceled") return "Cancelled";
if (raw === "checked_in") return "Checked In";
if (raw === "confirmed") return "Confirmed";
if (raw === "pending") return "Pending";
return String(s);
}
function formatListDate(iso: string | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
/* ──────────────────────────────────────
Participant lists (saved / rule-based)
────────────────────────────────────── */
export function ParticipantListsPage() {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const { setActiveNav } = useAppNav();
const [lists, setLists] = useState<ParticipantListRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [ruleListBeingEdited, setRuleListBeingEdited] = useState<ParticipantListRecord | null>(null);
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const [viewList, setViewList] = useState<ParticipantListRecord | null>(null);
const [mem, setMem] = useState<ListMemberRow[]>([]);
const [mloading, setMloading] = useState(false);
const [memPage, setMemPage] = useState(1);
const [memTotal, setMemTotal] = useState(0);
const memLimit = 50;
const [editTarget, setEditTarget] = useState<ParticipantListRecord | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [editVisibility, setEditVisibility] = useState<"Private" | "Shared">("Private");
const [editSaving, setEditSaving] = useState(false);
const [editErr, setEditErr] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [pageMsg, setPageMsg] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
if (!listOrgId) {
setLists([]);
setLoading(false);
return;
}
participantListsApi.list({ organization: listOrgId }).then((r) => {
setLoading(false);
if (r.data?.lists) setLists(r.data.lists);
}).catch(() => setLoading(false));
}, [listOrgId]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
if (!viewList) {
setMem([]);
setMemTotal(0);
setMemPage(1);
return;
}
if (!listOrgId) {
setMem([]);
setMemTotal(0);
return;
}
setMloading(true);
participantListsApi
.members(viewList._id, { organization: listOrgId, page: memPage, limit: memLimit })
.then((r) => {
setMloading(false);
if (r.data?.members) setMem(r.data.members);
setMemTotal(r.data?.total ?? 0);
})
.catch(() => setMloading(false));
}, [viewList?._id, memPage, listOrgId]);
const memPageCount = Math.max(1, Math.ceil(memTotal / memLimit));
const openEdit = (L: ParticipantListRecord) => {
if (L.type === "rule_based") {
setPageMsg(null);
setRuleListBeingEdited(L);
setShowCreate(true);
return;
}
setEditErr(null);
setEditTarget(L);
setEditName(L.name);
setEditDesc(L.description ?? "");
setEditVisibility(L.visibility === "shared" ? "Shared" : "Private");
};
const closeEdit = () => {
setEditTarget(null);
setEditErr(null);
};
const saveEdit = async () => {
if (!editTarget || !editName.trim()) {
setEditErr("Name is required.");
return;
}
setEditSaving(true);
setEditErr(null);
const payload: Parameters<typeof participantListsApi.update>[1] = {
name: editName.trim(),
description: editDesc.trim(),
visibility: editVisibility === "Shared" ? "shared" : "private",
};
const res = await participantListsApi.update(editTarget._id, payload);
setEditSaving(false);
if (res.error) {
setEditErr(res.error);
return;
}
setPageMsg(`Updated “${editName.trim()}”.`);
closeEdit();
load();
if (viewList?._id === editTarget._id && res.data) {
setViewList(res.data as ParticipantListRecord);
}
};
const runDelete = async () => {
if (!deleteTarget) return;
const { id, name } = deleteTarget;
setDeleteTarget(null);
const res = await participantListsApi.remove(id);
if (res.error) {
setPageMsg(res.error);
return;
}
setPageMsg(`Deleted “${name}”.`);
if (viewList?._id === id) setViewList(null);
load();
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{pageMsg && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<span>{pageMsg}</span>
<button type="button" onClick={() => setPageMsg(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)" }}><X size={16} /></button>
</div>
)}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: "12px" }}>
<div>
<p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participant lists</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
Fixed lists and rule-based lists. Open a list to see who is included.
</p>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
<button type="button" onClick={() => { setPageMsg(null); setActiveNav("all-participants"); }} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
All participants
</button>
<button type="button" onClick={() => { setPageMsg(null); setRuleListBeingEdited(null); setShowCreate(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Create Custom List
</button>
</div>
</div>
<ScrollTable minWidth={780}>
<div style={{ display: "grid", gridTemplateColumns: "minmax(160px,1.4fr) 110px 88px 100px minmax(260px,1fr)", padding: "10px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center", gap: "8px" }}>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>List</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Type</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Visibility</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Updated</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", textAlign: "right" }}>Actions</span>
</div>
{loading ? (
<div style={{ padding: "40px", textAlign: "center", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>Loading…</div>
) : lists.length === 0 ? (
<div style={{ padding: "48px 20px", textAlign: "center" }}>
<Users size={28} style={{ color: "var(--border)", margin: "0 auto 12px" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "6px" }}>No lists yet</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "16px" }}>Create a custom list from here or from All participants.</p>
<button type="button" onClick={() => { setRuleListBeingEdited(null); setShowCreate(true); }} style={{ display: "inline-flex", alignItems: "center", gap: "6px", padding: "8px 18px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={14} /> Create Custom List
</button>
</div>
) : (
lists.map((L) => (
<div
key={L._id}
onMouseEnter={() => setHoveredRow(L._id)}
onMouseLeave={() => setHoveredRow(null)}
style={{
display: "grid",
gridTemplateColumns: "minmax(160px,1.4fr) 110px 88px 100px minmax(260px,1fr)",
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
alignItems: "center",
gap: "8px",
background: hoveredRow === L._id ? "var(--secondary)" : "var(--card)",
transition: "background 0.12s",
}}
>
<div style={{ minWidth: 0 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{L.name}</p>
{L.description ? (
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "4px 0 0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{L.description}</p>
) : null}
</div>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{L.type === "rule_based" ? "Rule-based" : "Fixed"}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{L.visibility === "shared" ? "Shared" : "Private"}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatListDate(L.updatedAt)}</span>
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "6px", flexWrap: "wrap" }}>
<button
type="button"
onClick={() => { setMemPage(1); setViewList(L); }}
style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "5px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--primary)", background: "transparent", color: "var(--primary)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}
>
<Users size={12} /> View participants
</button>
<button type="button" title="Edit list" onClick={() => openEdit(L)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
<Pencil size={13} />
</button>
<button type="button" title="Delete list" onClick={() => setDeleteTarget({ id: L._id, name: L.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
<Trash2 size={13} />
</button>
</div>
</div>
))
)}
</ScrollTable>
{viewList && (
<>
<div onClick={() => setViewList(null)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.50)", zIndex: 200 }} />
<div style={{ position: "fixed", top: 0, right: 0, height: "100%", width: "min(520px,100vw)", background: "var(--card)", zIndex: 201, display: "flex", flexDirection: "column", boxShadow: "-4px 0 40px rgba(0,0,0,0.20)" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)", flexShrink: 0, display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" }}>
<div style={{ minWidth: 0 }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 4px" }}>Participants in list</p>
<h2 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-lg)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", margin: 0 }}>{viewList.name}</h2>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "6px 0 0" }}>
{viewList.type === "rule_based" ? "Rule-based" : "Fixed"} · {memTotal} {memTotal === 1 ? "person" : "people"}
</p>
</div>
<button type="button" onClick={() => setViewList(null)} title="Close" style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "28px", height: "28px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", color: "var(--muted)" }}>
<X size={14} />
</button>
</div>
<div style={{ flex: 1, overflowY: "auto" }}>
{mloading ? (
<p style={{ padding: "24px", fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Loading…</p>
) : (
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<thead>
<tr style={{ background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
{["Name", "Email", "Organization", "Status"].map((h) => (
<th key={h} style={{ textAlign: "left", padding: "10px 14px", fontWeight: "600", color: "var(--muted)", fontSize: "10px", textTransform: "uppercase" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{mem.map((m, i) => (
<tr key={`${m.participantId ?? "s"}-${i}`} style={{ borderBottom: "1px solid var(--border)" }}>
<td style={{ padding: "10px 14px" }}>{m.name}</td>
<td style={{ padding: "10px 14px", color: "var(--muted)", wordBreak: "break-word" }}>{m.email || "—"}</td>
<td style={{ padding: "10px 14px", color: "var(--muted)" }}>{m.organization || "—"}</td>
<td style={{ padding: "10px 14px" }}>{formatListMemberStatus(m.status)}</td>
</tr>
))}
</tbody>
</table>
)}
{!mloading && mem.length === 0 && (
<p style={{ padding: "20px", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>No participants in this list.</p>
)}
</div>
{viewList && !mloading && memTotal > memLimit && (
<div style={{ flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", padding: "14px 16px", borderTop: "1px solid var(--border)" }}>
<button type="button" disabled={memPage <= 1} onClick={() => setMemPage((p) => Math.max(1, p - 1))} style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: memPage <= 1 ? "not-allowed" : "pointer", opacity: memPage <= 1 ? 0.5 : 1, fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<ChevronLeft size={16} /> Previous
</button>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
{memPage} / {memPageCount} · {memTotal} total
</span>
<button type="button" disabled={memPage >= memPageCount} onClick={() => setMemPage((p) => p + 1)} style={{ display: "inline-flex", alignItems: "center", gap: "4px", padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: memPage >= memPageCount ? "not-allowed" : "pointer", opacity: memPage >= memPageCount ? 0.5 : 1, fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
Next <ChevronRight size={16} />
</button>
</div>
)}
</div>
</>
)}
{editTarget && (
<div role="dialog" aria-modal="true" style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 210, display: "flex", alignItems: "center", justifyContent: "center", padding: "16px" }} onClick={closeEdit}>
<div style={{ width: "100%", maxWidth: "420px", background: "var(--card)", borderRadius: "var(--radius-card)", border: "1px solid var(--border)", boxShadow: "var(--shadow-xl)", padding: "22px" }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-lg)", fontWeight: "var(--font-weight-medium)", margin: "0 0 16px", color: "var(--foreground)" }}>Edit list</h3>
{editErr && <p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", fontFamily: "var(--font-noto-sans)", margin: "0 0 12px" }}>{editErr}</p>}
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<Field>
<Label text="Name" required />
<input style={IS} value={editName} onChange={(e) => setEditName(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
<Field>
<Label text="Description" />
<textarea style={{ ...IS, resize: "vertical", minHeight: "72px" }} value={editDesc} onChange={(e) => setEditDesc(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
<Field>
<Label text="Visibility" required />
<RadioGroup name="plEditVis" value={editVisibility} onChange={(v) => setEditVisibility(v as "Private" | "Shared")} options={[{ label: "Private", value: "Private" }, { label: "Shared", value: "Shared" }]} />
</Field>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px", marginTop: "22px", paddingTop: "16px", borderTop: "1px solid var(--border)" }}>
<button type="button" onClick={closeEdit} disabled={editSaving} style={{ padding: "7px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: editSaving ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>Cancel</button>
<button type="button" onClick={() => void saveEdit()} disabled={editSaving} style={{ padding: "7px 16px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: editSaving ? "not-allowed" : "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>{editSaving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
)}
{deleteTarget && (
<ConfirmModal
title="Delete list"
message={`Delete “${deleteTarget.name}”? Participants in your database are not removed—only this list is deleted.`}
confirmLabel="Delete"
variant="danger"
onConfirm={() => void runDelete()}
onCancel={() => setDeleteTarget(null)}
/>
)}
{showCreate && (
<CreateCustomListPanel
key={ruleListBeingEdited?._id ?? "new-list-panel"}
editList={ruleListBeingEdited}
onClose={() => { setShowCreate(false); setRuleListBeingEdited(null); }}
onCreated={() => { load(); setPageMsg("Custom list created."); }}
onSaved={(doc) => {
load();
setPageMsg(`Updated “${doc.name}”.`);
if (viewList?._id === doc._id) setViewList(doc);
}}
/>
)}
<Footer />
</div>
);
}
/* ──────────────────────────────────────
Forms & Workflow
────────────────────────────────────── */
function formDisplayType(apiType: string): string {
if (apiType === "feedback") return "Feedback";
if (apiType === "registration") return "Registration";
if (apiType === "other") return "Other";
if (apiType === "survey" || apiType === "custom") return "Other";
return apiType;
}
function formEventName(f: FormWithSubmissionCount): string {
const ev = f.event;
if (!ev) return "—";
if (typeof ev === "string") return "—";
return (ev as { name?: string }).name?.trim() || "—";
}
function formEventId(f: FormWithSubmissionCount): string | null {
const ev = f.event;
if (!ev) return null;
if (typeof ev === "string") return ev.trim().length === 24 ? ev.trim() : null;
const id = (ev as { _id?: string })._id;
return id && String(id).trim().length === 24 ? String(id).trim() : null;
}
/** New form create must not send Mongo subdoc _id on fields (Joi / duplicate creation). */
function cloneFormFieldsForDuplicate(fields: unknown): unknown[] | undefined {
if (!Array.isArray(fields)) return undefined;
return fields.map((row) => {
if (!row || typeof row !== "object") return row;
const { _id, __v, ...rest } = row as Record<string, unknown>;
return rest;
});
}
function cloneSettingsForDuplicate(settings: unknown): unknown {
if (!settings || typeof settings !== "object") return settings;
const { _id, __v, ...rest } = settings as Record<string, unknown>;
return rest;
}
export function FormsPage({ subPage }: { subPage?: string }) {
const { user } = useAuth();
const { canModule } = usePermission();
const orgId = portfolioListOrganizationId(user);
const canReadForms = canModule("forms", "read");
const canWriteForms = canModule("forms", "write");
const canDeleteForms = canModule("forms", "delete");
const filterType = subPage === "forms-feedback" ? "Feedback"
: subPage === "forms-registration" ? "Registration"
: subPage === "forms-other" ? "Other"
: null;
const [forms, setForms] = useState<FormWithSubmissionCount[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null);
const [showFeedback, setShowFeedback] = useState(false);
const [showReg, setShowReg] = useState(false);
const [showOther, setShowOther] = useState(false);
const [editFormId, setEditFormId] = useState<string | null>(null);
const [formDeleteTarget, setFormDeleteTarget] = useState<FormWithSubmissionCount | null>(null);
const [formActionBusy, setFormActionBusy] = useState<string | null>(null);
const [formPageMsg, setFormPageMsg] = useState<string | null>(null);
const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null);
const [shareFormTarget, setShareFormTarget] = useState<FormWithSubmissionCount | null>(null);
const [copiedFormId, setCopiedFormId] = useState<string | null>(null);
const publicFormPageUrl = (f: FormWithSubmissionCount) => {
const base =
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) ||
window.location.origin.replace(/\/$/, "");
const eventId = formEventId(f);
const fid = f._id;
if (eventId) return `${base.replace(/\/$/, "")}/event/${eventId}/form/${fid}`;
return `${base.replace(/\/$/, "")}/form/${fid}`;
};
const FORM_LINK_COPIED_MSG = "Public form link copied to clipboard.";
const copyFormPublicLink = (f: FormWithSubmissionCount) => {
const url = publicFormPageUrl(f);
void navigator.clipboard.writeText(url).then(() => {
setCopiedFormId(f._id);
setFormPageMsg(FORM_LINK_COPIED_MSG);
window.setTimeout(() => {
setCopiedFormId(null);
setFormPageMsg((prev) => (prev === FORM_LINK_COPIED_MSG ? null : prev));
}, 4000);
}).catch(() => {
setFormPageMsg("Could not copy to clipboard. Check browser permissions or copy the link manually.");
});
};
const fetchForms = useCallback(async () => {
setLoadError(null);
if (!canReadForms) {
setForms([]);
return;
}
if (!orgId) {
setForms([]);
return;
}
setLoading(true);
try {
const list = await formsApi.list({ organization: orgId });
setForms(list);
} catch (err) {
setForms([]);
const msg = err instanceof Error && err.message ? err.message : "Could not load forms.";
setLoadError(msg);
} finally {
setLoading(false);
}
}, [canReadForms, orgId]);
useEffect(() => {
fetchForms();
}, [fetchForms]);
const filteredForms = filterType === "Feedback"
? forms.filter(f => f.type === "feedback")
: filterType === "Registration"
? forms.filter(f => f.type === "registration")
: filterType === "Other"
? forms.filter(f => f.type === "other" || f.type === "survey" || f.type === "custom")
: forms;
const openCreate = () => {
setEditFormId(null);
if (filterType === "Registration") setShowReg(true);
else if (filterType === "Other") setShowOther(true);
else setShowFeedback(true);
};
const openEdit = (formId: string) => {
setEditFormId(formId);
const f = forms.find(x => x._id === formId);
if (f?.type === "registration") setShowReg(true);
else if (f?.type === "other" || f?.type === "survey" || f?.type === "custom") setShowOther(true);
else setShowFeedback(true);
};
const handlePanelSuccess = () => {
fetchForms();
setShowFeedback(false);
setShowReg(false);
setShowOther(false);
setEditFormId(null);
setFormPageMsg("Form saved.");
};
const runDeleteForm = async () => {
if (!formDeleteTarget) return;
const id = formDeleteTarget._id;
const label = formDeleteTarget.name;
setFormActionBusy(id);
try {
await formsApi.remove(id);
setFormDeleteTarget(null);
setFormPageMsg(`Deleted “${label}”.`);
await fetchForms();
} catch {
setFormPageMsg("Could not delete the form. Try again.");
} finally {
setFormActionBusy(null);
}
};
const duplicateForm = async (f: FormWithSubmissionCount) => {
setFormActionBusy(f._id);
setFormPageMsg(null);
try {
const src = await formsApi.get(f._id);
const ev = src.event
? (typeof src.event === "string" ? src.event : (src.event as { _id?: string })._id)
: null;
if (!ev) {
setFormPageMsg("This form has no event. Edit it to attach an event, then duplicate.");
return;
}
const baseName = (src.name || "Form").trim();
const copyName = baseName.toLowerCase().endsWith(" (copy)") ? `${baseName} (2)` : `${baseName} (Copy)`;
await formsApi.create({
name: copyName,
type: src.type,
description: src.description ?? "",
fields: cloneFormFieldsForDuplicate(src.fields),
settings: cloneSettingsForDuplicate(src.settings),
event: String(ev),
});
setFormPageMsg(`Duplicated as “${copyName}”.`);
await fetchForms();
} catch (err) {
const msg = err instanceof Error ? err.message : "Could not duplicate the form. Try again.";
setFormPageMsg(msg);
} finally {
setFormActionBusy(null);
}
};
const pageTitle = subPage === "forms-add" ? "Add New Form" : filterType ?? "All Forms";
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div>
<p style={labelStyle}>Portfolio · Forms &amp; Workflow</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
{pageTitle}
</h1>
</div>
{canWriteForms ? (
<button onClick={openCreate} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Create Form
</button>
) : null}
</div>
{formPageMsg ? (
<p role="status" aria-live="polite" style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)", margin: 0, padding: "10px 14px", borderRadius: "var(--radius-card)", background: formPageMsg === FORM_LINK_COPIED_MSG ? "#dcfce7" : "var(--secondary)", border: formPageMsg === FORM_LINK_COPIED_MSG ? "1px solid #86efac" : "1px solid var(--border)" }}>
{formPageMsg}
</p>
) : null}
{!canReadForms ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Access denied" description="You do not have permission to view forms." />
</div>
) : !orgId ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
</div>
) : subPage === "forms-add" ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} />
</div>
) : loadError ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Could not load forms" description={loadError} cta="Try again" onCta={() => void fetchForms()} />
</div>
) : (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", gap: "6px" }}>
{["Form Name", "Event", "Type", "Submissions", "Status", ""].map(h => (
<span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>
))}
</div>
{loading ? (
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading forms…</div>
) : filteredForms.length === 0 ? (
<div style={{ padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="No forms found" description={`There are no ${pageTitle.toLowerCase()} to show for this organization.`} />
</div>
) : (
filteredForms.map(f => {
const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft";
const statusColor = status === "Active" ? { bg: "#dcfce7", color: "#15803d" } : status === "Draft" ? { bg: "var(--secondary)", color: "var(--muted)" } : { bg: "#f1f5f9", color: "#475569" };
const fid = f._id;
return (
<div key={fid}
onMouseEnter={() => setHovered(fid)}
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === fid ? "var(--secondary)" : "var(--card)", transition: "background 0.12s", gap: "6px" }}
>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={f.name}>{f.name}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={formEventName(f)}>{formEventName(f)}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formDisplayType(f.type)}</span>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{(f.submissionCount ?? 0).toLocaleString()}</span>
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: statusColor.bg, color: statusColor.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{status}</span>
<div style={{ display: "flex", gap: "4px", flexWrap: "wrap", justifyContent: "flex-end" }}>
<button
type="button"
title={copiedFormId === fid ? "Copied to clipboard" : "Copy public form link"}
onClick={() => copyFormPublicLink(f)}
disabled={formActionBusy === fid}
style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: copiedFormId === fid ? "#dcfce7" : "var(--card)", color: copiedFormId === fid ? "#15803d" : "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { if (copiedFormId !== fid) { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; } }}
onMouseLeave={e => { if (copiedFormId !== fid) { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; } }}
>
{copiedFormId === fid ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
</button>
{canReadForms ? (
<button type="button" title="Submissions" onClick={() => setSubmissionsPanelForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<List size={12} />
</button>
) : null}
{canWriteForms ? (
<button type="button" title="Edit" onClick={() => openEdit(fid)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Pencil size={12} />
</button>
) : null}
{canWriteForms ? (
<button type="button" title="Share" onClick={() => setShareFormTarget(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Share2 size={12} />
</button>
) : null}
{canWriteForms ? (
<button type="button" title="Duplicate" onClick={() => void duplicateForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Copy size={12} />
</button>
) : null}
{canDeleteForms ? (
<button type="button" title="Delete" onClick={() => { setFormPageMsg(null); setFormDeleteTarget(f); }} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}>
<Trash2 size={12} />
</button>
) : null}
</div>
</div>
);
})
)}
</div>
)}
{formDeleteTarget ? (
<ConfirmModal
title="Delete form"
message={`Delete “${formDeleteTarget.name}”? Submissions stored for this form will no longer be linked to a form definition.`}
confirmLabel="Delete"
variant="danger"
onConfirm={() => void runDeleteForm()}
onCancel={() => setFormDeleteTarget(null)}
/>
) : null}
<Footer />
{showFeedback && canWriteForms && (
<CreateFormPanel formId={editFormId ?? undefined} mode="feedback" onClose={() => { setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)}
{showReg && canWriteForms && (
<CreateFormPanel formId={editFormId ?? undefined} mode="registration" onClose={() => { setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)}
{showOther && canWriteForms && (
<CreateFormPanel formId={editFormId ?? undefined} mode="other" onClose={() => { setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)}
{shareFormTarget ? (
<ShareFormPanel formId={shareFormTarget._id} onClose={() => setShareFormTarget(null)} />
) : null}
{submissionsPanelForm ? (
<FormSubmissionsPanel
formId={submissionsPanelForm._id}
formName={submissionsPanelForm.name}
onClose={() => setSubmissionsPanelForm(null)}
/>
) : null}
</div>
);
}
/* ──────────────────────────────────────
Content
────────────────────────────────────── */
function formatLibraryBytes(n?: number): string {
if (n == null || Number.isNaN(n)) return "—";
if (n < 1024) return `${n} B`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1048576).toFixed(1)} MB`;
}
function libraryTypeLabel(c: LibraryContent): string {
const t = c.type;
if (t === "image") return "Image";
if (t === "pdf") return "PDF";
if (t === "video") return "Video";
return "Other";
}
function formatShortDate(iso?: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
} catch {
return "—";
}
}
export function ContentPage({ subPage }: { subPage?: string }) {
const { user } = useAuth();
const orgId = portfolioListOrganizationId(user);
const canManageContent = Boolean(orgId) || user?.role === "super_admin";
const isCollections = subPage === "content-collections";
const [hovered, setHovered] = useState<string | null>(null);
const [showUpload, setShowUpload] = useState(false);
const [showCollection, setShowCollection] = useState(false);
const [editingCollection, setEditingCollection] = useState<ContentCollectionRecord | null>(null);
const [library, setLibrary] = useState<LibraryContent[]>([]);
const [collections, setCollections] = useState<ContentCollectionRecord[]>([]);
const [loading, setLoading] = useState(false);
const refreshLibrary = useCallback(async () => {
if (!canManageContent) return;
if (!orgId) {
setLibrary([]);
return;
}
setLoading(true);
try {
const { media } = await listMedia({ limit: 400, organization: orgId });
setLibrary(media);
} catch {
setLibrary([]);
} finally {
setLoading(false);
}
}, [canManageContent, orgId]);
const refreshCollections = useCallback(async () => {
if (!canManageContent) return;
if (!orgId) {
setCollections([]);
return;
}
setLoading(true);
try {
const list = await contentCollectionsApi.list({ organization: orgId });
setCollections(list);
} catch {
setCollections([]);
} finally {
setLoading(false);
}
}, [canManageContent, orgId]);
useEffect(() => {
if (isCollections) void refreshCollections();
else void refreshLibrary();
}, [isCollections, refreshCollections, refreshLibrary]);
const handleContentSuccess = () => {
if (isCollections) void refreshCollections();
else void refreshLibrary();
};
return (
<>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div>
<p style={labelStyle}>Portfolio · Content</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{isCollections ? "Collections" : "Library"}</h1>
</div>
{canManageContent && (
<div style={{ display: "flex", gap: "8px" }}>
<button
type="button"
onClick={() => {
if (isCollections) {
setEditingCollection(null);
setShowCollection(true);
} else setShowUpload(true);
}}
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = "0.88"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = "1"; }}
>
<Plus size={13} /> {isCollections ? "New Collection" : "Add Files"}
</button>
</div>
)}
</div>
{!canManageContent ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>Associate your account with an organization to manage content.</p>
) : (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
{loading ? (
<div style={{ padding: "20px", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>Loading…</div>
) : isCollections ? (
<>
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 100px 56px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
{["Collection", "Items", "Created", ""].map(h => <span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>)}
</div>
{collections.length === 0 ? (
<div style={{ padding: "24px", textAlign: "center", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>No collections yet.</div>
) : collections.map(col => (
<div key={col._id}
onMouseEnter={() => setHovered(col._id)}
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "1fr 80px 100px 56px", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === col._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s", cursor: "default" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div style={{ width: "32px", height: "32px", borderRadius: "var(--radius-button)", background: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<BookOpen size={14} style={{ color: "var(--primary)" }} />
</div>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{col.name}</span>
</div>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{col.contentIds?.length ?? 0}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatShortDate(col.createdAt)}</span>
<div style={{ display: "flex", justifyContent: "flex-end", opacity: hovered === col._id ? 1 : 0, transition: "opacity 0.15s" }}>
<button
type="button"
title="Edit collection"
onClick={(e) => {
e.stopPropagation();
setEditingCollection(col);
setShowCollection(true);
}}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: "32px", height: "32px", borderRadius: "var(--radius-button)",
border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", color: "var(--muted)",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<Pencil size={14} />
</button>
</div>
</div>
))}
</>
) : (
<>
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 80px 80px 50px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
{["File", "Type", "Size", "Added", ""].map(h => <span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>)}
</div>
{library.length === 0 ? (
<div style={{ padding: "24px", textAlign: "center", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>No files yet. Use Add Files to upload.</div>
) : library.map(f => (
<div key={f._id}
onMouseEnter={() => setHovered(f._id)}
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "1fr 80px 80px 80px 50px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === f._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)" }}>{f.name}</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px", width: "fit-content" }}>{libraryTypeLabel(f)}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLibraryBytes(f.size)}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatShortDate(f.createdAt)}</span>
<div style={{ opacity: hovered === f._id ? 1 : 0, transition: "opacity 0.15s" }}>
{f.url && (
<a href={f.url} target="_blank" rel="noopener noreferrer" title="Open / download" style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex" }}
onClick={(e) => e.stopPropagation()}>
<Download size={13} />
</a>
)}
</div>
</div>
))}
</>
)}
</div>
)}
<Footer />
</div>
{showUpload && canManageContent && (
<UploadFilePanel onClose={() => setShowUpload(false)} onSuccess={() => { handleContentSuccess(); setShowUpload(false); }} />
)}
{showCollection && canManageContent && (
<CreateCollectionPanel
key={editingCollection ? `edit-${editingCollection._id}` : "create"}
editing={editingCollection}
onClose={() => { setShowCollection(false); setEditingCollection(null); }}
onSuccess={() => { handleContentSuccess(); setShowCollection(false); setEditingCollection(null); }}
/>
)}
</>
);
}
/* ──────────────────────────────────────
Messaging
────────────────────────────────────── */
export function MessagingPage({ subPage }: { subPage?: string }) {
if (subPage === "messaging-email") return <MessagingEmailPage />;
if (subPage === "messaging-whatsapp") return <MessagingWhatsappPage />;
return <MessagingEmailPage />;
}
/* ──────────────────────────────────────
User Management (Global)
────────────────────────────────────── */
const ROLE_LABELS: Record<string, string> = {
super_admin: "Super Admin",
admin: "Admin",
moderator: "Moderator",
viewer: "Viewer",
};
const ROLE_STYLES: Record<string, { bg: string; color: string }> = {
super_admin: { bg: "#fef3c7", color: "#92400e" },
admin: { bg: "var(--accent)", color: "var(--accent-foreground)" },
moderator: { bg: "#dcfce7", color: "#15803d" },
viewer: { bg: "var(--secondary)", color: "var(--muted)" },
};
function userDisplayName(u: User): string {
if (u.firstName || u.lastName) return [u.firstName, u.lastName].filter(Boolean).join(" ").trim();
return u.email?.split("@")[0] ?? "User";
}
function userInitials(u: User): string {
const name = userDisplayName(u);
return name.slice(0, 2).toUpperCase() || "?";
}
export function UserManagementPage() {
const { setActiveNav } = useAppNav();
const { canModule } = usePermission();
const canReadUsers = canModule("user_management", "read");
const canWriteUsers = canModule("user_management", "write");
const canDeleteUsers = canModule("user_management", "delete");
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [hovered, setHovered] = useState<string | null>(null);
const [showInvite, setShowInvite] = useState(false);
const [editUser, setEditUser] = useState<User | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
try {
const res = await usersApi.list();
setUsers(res.data?.users ?? []);
} catch {
setUsers([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{!canReadUsers ? (
<div style={{ padding: 32, textAlign: "center", color: "var(--muted)" }}>You do not have permission to view user management.</div>
) : (
<>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div>
<p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>User Management</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{users.length} users across the platform</p>
</div>
<div style={{ display: "flex", gap: 8 }}>
{canModule("user_management", "read") ? (
<button type="button" onClick={() => setActiveNav("roles-management")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--secondary)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: "pointer", fontSize: "var(--text-sm)" }}>
<Shield size={13} /> Manage Roles
</button>
) : null}
{canWriteUsers ? (
<button onClick={() => { setEditUser(null); setShowInvite(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Invite User
</button>
) : null}
</div>
</div>
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr 120px 80px 60px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
{["User", "Email", "Role", "Events", ""].map(h => <span key={h} style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.05em" }}>{h}</span>)}
</div>
{loading ? (
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading users…</div>
) : (
users.map((u, idx) => {
const rs = ROLE_STYLES[u.membershipRole ?? u.role] ?? ROLE_STYLES[u.role] ?? { bg: "var(--secondary)", color: "var(--muted)" };
const roleLabel = u.customRole?.name ?? ROLE_LABELS[u.membershipRole ?? u.role] ?? u.role;
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
const assigned = u.assignedEvents?.length ?? 0;
const eventsLabel = assigned > 0 ? String(assigned) : (u.role === "super_admin" || u.role === "admin" ? "All" : "—");
return (
<div key={u._id}
onMouseEnter={() => setHovered(u._id)}
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr 120px 80px 60px", alignItems: "center", padding: "12px 16px", borderBottom: "1px solid var(--border)", background: hovered === u._id ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div style={{ width: "30px", height: "30px", borderRadius: "50%", background: color, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{userInitials(u)}</span>
</div>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{userDisplayName(u)}</span>
</div>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{u.email}</span>
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: rs.bg, color: rs.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{roleLabel}</span>
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{eventsLabel}</span>
<div style={{ display: "flex", gap: "4px", opacity: hovered === u._id ? 1 : 0, transition: "opacity 0.15s" }}>
{canWriteUsers ? (
<button type="button" onClick={() => { setEditUser(u); setShowInvite(true); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Pencil size={11} />
</button>
) : null}
{canDeleteUsers ? (
<button type="button" onClick={() => { if (window.confirm(`Remove ${userDisplayName(u)}?`)) void usersApi.remove(u._id).then(() => fetchUsers()); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
<Trash2 size={11} />
</button>
) : null}
</div>
</div>
);
})
)}
</div>
<Footer />
{showInvite && canWriteUsers && <InviteUserPanel editUser={editUser} onClose={() => { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
</>
)}
</div>
);
}
/* ──────────────────────────────────────
Footer
────────────────────────────────────── */
function Footer() {
return (
<div style={{ paddingTop: "8px", borderTop: "1px solid var(--border)", textAlign: "center" }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>© 2026 Stratawiz Technologies Pvt Ltd · EventBuzz</p>
</div>
);
}