This commit is contained in:
Ravi Gulhane 2026-07-01 16:44:19 +05:30
parent 50cfd63778
commit 76cfbf5581
60 changed files with 9467 additions and 4646 deletions

File diff suppressed because one or more lines are too long

2600
dist/assets/index-Cq3hylG7.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/index.html vendored
View File

@ -12,7 +12,7 @@
rel="stylesheet"
/>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" crossorigin src="/assets/index-CWH8CnCf.js"></script>
<script type="module" crossorigin src="/assets/index-Cq3hylG7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B2aqbk3U.css">
</head>

View File

@ -36,6 +36,13 @@ export function AppShell() {
setActiveNav("dashboard");
}, [selectedEventId, activeNav, setActiveNav]);
useEffect(() => {
if (!selectedEventId) return;
if (activeNav === "questions" || activeNav === "quiz") {
setActiveNav("event-activities");
}
}, [selectedEventId, activeNav, setActiveNav]);
return (
<div
style={{

View File

@ -1,12 +1,10 @@
import React, { useRef, useState, useEffect, useCallback } from "react";
import { MessageCircle, Star, Plus, X } from "lucide-react";
import React, { useState, useEffect, useCallback } from "react";
import { useAppNav } from "../contexts/AppNavContext";
import { useBreakpoint } from "../hooks/useBreakpoint";
import { CreateEventPanel } from "./panels/CreateEventPanel";
import { AgendaSessionPanel } from "./agenda/AgendaSessionPanel";
import { QuickActions } from "./dashboard/QuickActions";
import { FeatureCards } from "./dashboard/FeatureCards";
import { ChartsSection } from "./dashboard/ChartsSection";
import { EventHomeHeaderActions } from "./dashboard/EventHomeHeaderActions";
import { EventHomeModules } from "./dashboard/EventHomeModules";
import { EventAnalyticsPage } from "./analytics/EventAnalyticsPage";
import { EventHeader } from "./dashboard/EventHeader";
import { AgendaPage } from "./agenda/AgendaPage";
import { ParticipantsPage } from "./participants/ParticipantsPage";
@ -16,8 +14,7 @@ import { EventsPage } from "./events/EventsPage";
import { ActivitiesPage } from "./activities/ActivitiesPage";
import { ActivityDetailPage } from "./activities/ActivityDetailPage";
import { PermissionsPage } from "./permissions/PermissionsPage";
import { QuestionsPage } from "./questions/QuestionsPage";
import { QuizPollsPage } from "./quiz/QuizPollsPage";
import { EventActivitiesPage } from "./quiz/QuizPollsPage";
import { RewardPointsPage } from "./rewards/RewardPointsPage";
import { EventTicketingPage } from "./ticketing/EventTicketingPage";
import { WebsiteBuilderPage } from "./website/WebsiteBuilderPage";
@ -36,7 +33,7 @@ import { SettingsPage } from "./global/SettingsPage";
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
import { SearchResultsPage } from "./search/SearchResultsPage";
import { eventsApi, tracksApi, sessionsApi, type ApiTrack, type ApiSession } from "../../services/api";
import { eventsApi } from "../../services/api";
import type { Event } from "../../services/api/types";
export function MainContent() {
@ -50,22 +47,8 @@ export function MainContent() {
settingsInitialSection,
setSettingsInitialSection,
} = useAppNav();
const [dashModal, setDashModal] = useState<"feedback" | "ratings" | null>(null);
const [editEventPanelOpen, setEditEventPanelOpen] = useState(false);
const [dashSessionPanel, setDashSessionPanel] = useState<{
initialTrackId?: string | null;
sessionId?: string | null;
} | null>(null);
const [dashAgendaEventName, setDashAgendaEventName] = useState("");
const [dashAgendaEventStartIso, setDashAgendaEventStartIso] = useState(() => new Date().toISOString());
const [dashAgendaOrgId, setDashAgendaOrgId] = useState("");
const [dashAgendaTracks, setDashAgendaTracks] = useState<ApiTrack[]>([]);
const [dashSessionById, setDashSessionById] = useState<Record<string, ApiSession>>({});
const [dashSessionPanelOpening, setDashSessionPanelOpening] = useState(false);
const [dashMoreOpen, setDashMoreOpen] = useState(false);
const dashMoreRef = useRef<HTMLDivElement>(null);
const bp = useBreakpoint();
const isSmall = bp === "mobile" || bp === "tablet";
const [dashEvent, setDashEvent] = useState<Event | null>(null);
const [dashLoading, setDashLoading] = useState(false);
@ -84,56 +67,11 @@ export function MainContent() {
}
}, [selectedEventId]);
const openDashboardSessionPanel = useCallback(async () => {
if (!selectedEventId || dashSessionPanelOpening) return;
setDashSessionPanelOpening(true);
try {
const [evRes, trRes, sesRes] = await Promise.all([
eventsApi.get(selectedEventId),
tracksApi.list(selectedEventId),
sessionsApi.list(selectedEventId),
]);
const ev = evRes.data as {
name?: string;
startDate?: string;
organization?: string | { _id: string };
} | null;
setDashAgendaEventName(ev?.name || dashEvent?.name || "Event");
setDashAgendaEventStartIso(ev?.startDate || new Date().toISOString());
const org =
ev?.organization && typeof ev.organization === "object" && "_id" in ev.organization
? String(ev.organization._id)
: ev?.organization
? String(ev.organization)
: "";
setDashAgendaOrgId(org);
const rawTracks = (trRes.data as { tracks?: ApiTrack[] } | undefined)?.tracks || [];
setDashAgendaTracks(rawTracks);
const sessions = (sesRes.data as { sessions?: ApiSession[] } | undefined)?.sessions || [];
const byId: Record<string, ApiSession> = {};
sessions.forEach((s) => {
byId[s._id] = s;
});
setDashSessionById(byId);
setDashSessionPanel({});
} finally {
setDashSessionPanelOpening(false);
}
}, [selectedEventId, dashSessionPanelOpening, dashEvent?.name]);
useEffect(() => {
if (activeNav !== "dashboard" && activeNav !== "analytics") return;
void loadDashboardData();
}, [activeNav, loadDashboardData]);
useEffect(() => {
const h = (e: MouseEvent) => {
if (dashMoreRef.current && !dashMoreRef.current.contains(e.target as Node)) setDashMoreOpen(false);
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const handleEventSelect = (eventId: string) => {
setSelectedEventId(eventId);
setActiveNav("dashboard");
@ -200,84 +138,12 @@ export function MainContent() {
if (activeNav === "attendance") return <AttendancePage />;
if (activeNav === "ticketing") return <EventTicketingPage />;
if (activeNav === "permissions") return <PermissionsPage />;
if (activeNav === "questions") return <QuestionsPage />;
if (activeNav === "quiz") return <QuizPollsPage />;
if (activeNav === "event-activities") return <EventActivitiesPage />;
if (activeNav === "rewards") return <RewardPointsPage eventId={selectedEventId} />;
if (activeNav === "website") return <WebsiteBuilderPage />;
if (activeNav === "analytics") {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
marginBottom: "4px",
}}
>
{dashLoading && selectedEventId
? "Loading…"
: dashEvent?.name ?? (selectedEventId ? "Event" : "Select an event")}
</p>
<h1
style={{
fontFamily: "var(--font-inter)",
fontSize: bp === "mobile" ? "var(--text-xl)" : "var(--text-2xl)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
lineHeight: "1.2",
}}
>
Analytics
</h1>
</div>
<div>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
fontWeight: "var(--font-weight-medium)",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "10px",
}}
>
Charts
</p>
<ChartsSection />
</div>
<div>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
fontWeight: "var(--font-weight-medium)",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "10px",
}}
>
Features
</p>
<FeatureCards />
</div>
<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>
</div>
);
return <EventAnalyticsPage eventId={selectedEventId} onNavigate={setActiveNav} />;
}
if (activeNav === "dashboard") {
@ -314,156 +180,10 @@ export function MainContent() {
lineHeight: "1.2",
}}
>
Dashboard
Event Home
</h1>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
{!isSmall && (
<>
<button
type="button"
onClick={() => setDashModal("feedback")}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
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",
}}
>
<MessageCircle size={14} /> Feedback
</button>
<button
type="button"
onClick={() => setDashModal("ratings")}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
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",
}}
>
<Star size={14} /> Ratings
</button>
</>
)}
{isSmall && (
<div ref={dashMoreRef} style={{ position: "relative" }}>
<button
type="button"
onClick={() => setDashMoreOpen((v) => !v)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "36px",
height: "36px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--card-foreground)",
cursor: "pointer",
}}
>
···
</button>
{dashMoreOpen && (
<div
style={{
position: "absolute",
right: 0,
top: "calc(100% + 6px)",
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
boxShadow: "var(--elevation-sm)",
zIndex: 50,
minWidth: "160px",
overflow: "hidden",
}}
>
{[
{
label: "Feedback",
icon: <MessageCircle size={13} />,
action: () => {
setDashModal("feedback");
setDashMoreOpen(false);
},
},
{
label: "Ratings",
icon: <Star size={13} />,
action: () => {
setDashModal("ratings");
setDashMoreOpen(false);
},
},
].map((item) => (
<button
key={item.label}
type="button"
onClick={item.action}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 14px",
background: "none",
border: "none",
cursor: "pointer",
textAlign: "left",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
borderBottom: "1px solid var(--border)",
}}
>
{item.icon} {item.label}
</button>
))}
</div>
)}
</div>
)}
<button
type="button"
disabled={!selectedEventId || dashSessionPanelOpening}
onClick={() => void openDashboardSessionPanel()}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "7px 16px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: !selectedEventId || dashSessionPanelOpening ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: !selectedEventId || dashSessionPanelOpening ? 0.6 : 1,
}}
>
<Plus size={14} />{" "}
{dashSessionPanelOpening ? "Loading…" : isSmall ? "New Session" : "Create New Session"}
</button>
</div>
<EventHomeHeaderActions />
</div>
<EventHeader
@ -483,8 +203,7 @@ export function MainContent() {
/>
)}
{/* Overview section (hidden for now) */}
<QuickActions />
<EventHomeModules />
<AgendaPage embedInDashboard />
@ -506,171 +225,5 @@ export function MainContent() {
return <AgendaPage />;
};
return (
<>
{pageContent()}
{dashModal && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.35)",
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => setDashModal(null)}
role="dialog"
aria-modal="true"
>
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "440px",
maxWidth: "92vw",
boxShadow: "var(--elevation-sm)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
}}
>
<h3
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-base)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
}}
>
{dashModal === "ratings" ? "Event Ratings" : "Attendee Feedback"}
</h3>
<button
type="button"
onClick={() => setDashModal(null)}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--muted)",
display: "flex",
padding: "2px",
}}
>
<X size={16} />
</button>
</div>
<div style={{ padding: "20px", display: "flex", flexDirection: "column", gap: "10px" }}>
{dashModal === "ratings" &&
[
{ user: "Dr. Sharma", rating: 5, comment: "Excellent conference, very informative!" },
{ user: "Prof. Mehta", rating: 4, comment: "Well organized, good speakers." },
{ user: "Dr. Patel", rating: 5, comment: "Best conference of the year!" },
].map((r, i) => (
<div
key={i}
style={{
padding: "12px 14px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
}}
>
{r.user}
</span>
<span style={{ color: "var(--chart-2)", fontSize: "var(--text-sm)" }}>
{"★".repeat(r.rating)}
{"☆".repeat(5 - r.rating)}
</span>
</div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{r.comment}</p>
</div>
))}
{dashModal === "feedback" &&
[
{ user: "Ananya R.", time: "2h ago", msg: "The morning keynote was excellent." },
{ user: "Rajesh K.", time: "4h ago", msg: "Please improve the AV setup for Session 3." },
{ user: "Priya M.", time: "1d ago", msg: "Great networking opportunities. Well done!" },
].map((f, i) => (
<div
key={i}
style={{ display: "flex", gap: "10px", padding: "12px 14px", border: "1px solid var(--border)", borderRadius: "var(--radius-button)" }}
>
<div
style={{
width: "28px",
height: "28px",
borderRadius: "50%",
background: "var(--accent)",
color: "var(--accent-foreground)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-inter)",
flexShrink: 0,
}}
>
{f.user[0]}
</div>
<div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginBottom: "3px" }}>
<span
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
}}
>
{f.user}
</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{f.time}</span>
</div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{f.msg}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
{dashSessionPanel && selectedEventId && (
<AgendaSessionPanel
eventId={selectedEventId}
eventName={dashAgendaEventName}
eventStartIso={dashAgendaEventStartIso}
organizationId={dashAgendaOrgId}
tracks={dashAgendaTracks}
initialTrackId={dashSessionPanel.initialTrackId ?? undefined}
session={dashSessionPanel.sessionId ? dashSessionById[dashSessionPanel.sessionId] ?? null : null}
onClose={() => setDashSessionPanel(null)}
onSuccess={() => {
setDashSessionPanel(null);
void loadDashboardData();
}}
onTrackCreated={(t) => setDashAgendaTracks((prev) => [...prev, t])}
/>
)}
</>
);
return <>{pageContent()}</>;
}

View File

@ -1,7 +1,7 @@
import React, { useRef, useState, useEffect } from "react";
import {
LayoutDashboard, LineChart, Users, ShieldCheck,
MessageSquare, BarChart2, Trophy, Ticket, ClipboardCheck,
LayoutDashboard, LineChart, Users,
BarChart2, Trophy, ClipboardCheck, Zap,
} from "lucide-react";
import { useBreakpoint } from "../hooks/useBreakpoint";
@ -16,19 +16,16 @@ interface EventTab {
}
const EVENT_TABS: EventTab[] = [
{ id: "dashboard", label: "Dashboard", shortLabel: "Dash", icon: <LayoutDashboard size={13} /> },
{ id: "dashboard", label: "Event Home", shortLabel: "Home", icon: <LayoutDashboard size={13} /> },
{ id: "participants", label: "Participants", shortLabel: "People", icon: <Users size={13} /> },
{ id: "attendance", label: "Attendance", shortLabel: "Attend", icon: <ClipboardCheck size={13} /> },
{ id: "permissions", label: "Users & Permissions", shortLabel: "Permissions", icon: <ShieldCheck size={13} /> },
{ id: "questions", label: "Questions", shortLabel: "Q&A", icon: <MessageSquare size={13} /> },
{ id: "quiz", label: "Quiz & Polls", shortLabel: "Quiz", icon: <BarChart2 size={13} /> },
{ id: "event-activities", label: "Activities", shortLabel: "Activities", icon: <Zap size={13} /> },
{ id: "rewards", label: "Reward Points", shortLabel: "Rewards", icon: <Trophy size={13} /> },
{ id: "ticketing", label: "Ticketing", shortLabel: "Tickets", icon: <Ticket size={13} /> },
{ id: "analytics", label: "Analytics", shortLabel: "Analytics", icon: <LineChart size={13} /> },
];
/** Event sub-routes that keep the event NavBar visible (includes hidden tabs like the website builder). */
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website"]);
export const EVENT_NAV_IDS = new Set([...EVENT_TABS.map((t) => t.id), "website", "permissions", "ticketing", "questions", "quiz"]);
/* ─────────────────────────────────────
NavBar component
@ -96,7 +93,7 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
}}
>
{EVENT_TABS.map(tab => {
const isActive = activeItem === tab.id;
const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz"));
return (
<button
key={tab.id}

View File

@ -21,6 +21,11 @@ import {
type QuizPollModalForm,
} from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return modalQuestionToApi(q, kind);
}
type ActivityUiType = "Q&A Session" | "Quiz" | "Poll";
type ActivityStatusUi = "Active" | "Scheduled" | "Completed" | "Draft";
@ -144,21 +149,6 @@ const STATUS_BADGE: Record<ActivityStatusUi, { bg: string; color: string }> = {
Draft: { bg: "#ffedd5", color: "#c2410c" },
};
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return {
question: q.text,
imageUrl: q.imageUrl?.trim() ? q.imageUrl.trim() : null,
type: (q.type === "Single Choice" || q.type === "True / False" ? "single" : q.type === "Multiple Choice" ? "multiple" : "text") as "single" | "multiple" | "text" | "rating",
options: q.options?.map(o => ({
text: o.text,
isCorrect: o.isCorrect,
imageUrl: o.imageUrl?.trim() ? o.imageUrl.trim() : null,
})),
required: true as const,
...(kind === "quiz" ? { points: 1 } : {}),
};
}
function ActivitySidePanel({
title,
subtitle,

View File

@ -7,11 +7,11 @@ import { useAppNav } from "../../contexts/AppNavContext";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { eventsApi } from "../../../services/api/events";
import { quizPollsApi } from "../../../services/api";
import { sessionsApi, type ApiSession, type SessionPayload } from "../../../services/api/sessions";
import { sessionsApi, type ApiSession } from "../../../services/api/sessions";
import type { Event } from "../../../services/api/types";
import type { ApiTrack } from "../../../services/api/tracks";
import { QuestionsPage } from "../questions/QuestionsPage";
import { QuizPollsPage } from "../quiz/QuizPollsPage";
import { effectiveEnableQnA, effectiveEnableQnP, sessionToPayload } from "../../utils/sessionFlags";
import { ParticipantsPage } from "../participants/ParticipantsPage";
import { AgendaQrModal } from "../agenda/AgendaQrModal";
import { ActivityEventSettings } from "./ActivityEventSettings";
@ -22,20 +22,10 @@ import {
type QuizPollModalForm,
} from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return {
question: q.text,
imageUrl: q.imageUrl?.trim() ? q.imageUrl.trim() : null,
type: (q.type === "Single Choice" || q.type === "True / False" ? "single" : q.type === "Multiple Choice" ? "multiple" : "text") as "single" | "multiple" | "text" | "rating",
options: q.options?.map((o) => ({
text: o.text,
isCorrect: o.isCorrect,
imageUrl: o.imageUrl?.trim() ? o.imageUrl.trim() : null,
})),
required: true as const,
...(kind === "quiz" ? { points: 1 } : {}),
};
return modalQuestionToApi(q, kind);
}
function activityProgress(ev: Event): number {
@ -47,49 +37,6 @@ function activityProgress(ev: Event): number {
return Math.min(100, Math.round(q + p + r));
}
/** Resolved flags for tabs (supports legacy `qnaEnabled` on older session documents). */
function effectiveEnableQnA(s: ApiSession): boolean {
if (typeof s.enableQnA === "boolean") return s.enableQnA;
const legacy = (s as ApiSession & { qnaEnabled?: boolean }).qnaEnabled;
if (typeof legacy === "boolean") return legacy;
return true;
}
function effectiveEnableQnP(s: ApiSession): boolean {
if (typeof s.enableQnP === "boolean") return s.enableQnP;
return true;
}
function sessionToPayload(s: ApiSession): SessionPayload {
const trackRaw = s.track;
let track: string | null = null;
if (trackRaw && typeof trackRaw === "object" && "_id" in trackRaw) track = String((trackRaw as ApiTrack)._id);
else if (typeof trackRaw === "string" && trackRaw.trim()) track = trackRaw;
const speakers = (s.speakers || []).map((x) => (typeof x === "string" ? x : x._id));
return {
title: s.title,
description: s.description ?? "",
type: s.type,
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
track,
speakers,
moderator: s.moderator ?? null,
capacity: s.capacity,
status: s.status,
resources: s.resources,
audienceFeedbackEnabled: s.audienceFeedbackEnabled,
audienceRatingsEnabled: s.audienceRatingsEnabled,
audienceFeedbackForm: s.audienceFeedbackForm ?? null,
audienceRatingsForm: s.audienceRatingsForm ?? null,
enableQnA: effectiveEnableQnA(s),
enableQnP: effectiveEnableQnP(s),
};
}
type DetailTab = "questions" | "quiz-polls" | "participants" | "settings";
export function ActivityDetailPage() {

View File

@ -4,9 +4,9 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Plus, Trash2, Search } from "lucide-react";
import { eventsApi } from "../../../services/api/events";
import { sessionsApi, type ApiSession, type SessionPayload } from "../../../services/api/sessions";
import { sessionsApi, type ApiSession } from "../../../services/api/sessions";
import type { Event } from "../../../services/api/types";
import type { ApiTrack } from "../../../services/api/tracks";
import { effectiveEnableQnA, effectiveEnableQnP, sessionToPayload } from "../../utils/sessionFlags";
import { buildEventCreatePayload, labelToCode, needsChoiceOptions, type EventCreateFormInput } from "../panels/createEventPayload";
import {
IS,
@ -31,48 +31,6 @@ import {
type EventForm,
} from "../panels/eventFormModel";
function effectiveEnableQnA(s: ApiSession): boolean {
if (typeof s.enableQnA === "boolean") return s.enableQnA;
const legacy = (s as ApiSession & { qnaEnabled?: boolean }).qnaEnabled;
if (typeof legacy === "boolean") return legacy;
return true;
}
function effectiveEnableQnP(s: ApiSession): boolean {
if (typeof s.enableQnP === "boolean") return s.enableQnP;
return true;
}
function sessionToPayload(s: ApiSession): SessionPayload {
const trackRaw = s.track;
let track: string | null = null;
if (trackRaw && typeof trackRaw === "object" && "_id" in trackRaw) track = String((trackRaw as ApiTrack)._id);
else if (typeof trackRaw === "string" && trackRaw.trim()) track = trackRaw;
const speakers = (s.speakers || []).map((x) => (typeof x === "string" ? x : x._id));
return {
title: s.title,
description: s.description ?? "",
type: s.type,
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
track,
speakers,
moderator: s.moderator ?? null,
capacity: s.capacity,
status: s.status,
resources: s.resources,
audienceFeedbackEnabled: s.audienceFeedbackEnabled,
audienceRatingsEnabled: s.audienceRatingsEnabled,
audienceFeedbackForm: s.audienceFeedbackForm ?? null,
audienceRatingsForm: s.audienceRatingsForm ?? null,
enableQnA: effectiveEnableQnA(s),
enableQnP: effectiveEnableQnP(s),
};
}
export function ActivityEventSettings({
event,
session,

View File

@ -1,11 +1,11 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { useAppNav } from "../../contexts/AppNavContext";
import { eventsApi, tracksApi, sessionsApi, type ApiTrack, type ApiSession } from "../../../services/api";
import { AgendaCreateTrackPanel } from "./AgendaCreateTrackPanel";
import { AgendaSessionPanel, UNASSIGNED_TRACK_ID } from "./AgendaSessionPanel";
import {
ChevronDown, ChevronRight, Plus, Pencil, Trash2, Copy,
GripVertical, Clock, MapPin, Star, MessageCircle,
GripVertical, Clock, MapPin,
List, LayoutGrid, X, Calendar, User, AlertCircle,
Loader2, QrCode,
} from "lucide-react";
@ -231,16 +231,6 @@ function SessionRow({ session, onEdit, onDelete, onDuplicate, onOpenQr, duplicat
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}>
{duplicateLoading ? <Loader2 size={13} className="animate-spin" /> : <Copy size={13} />}
</button>
<button type="button" title="View Feedback" onClick={(e) => { e.stopPropagation(); }} style={actionIconBtn}
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(--card-foreground)"; }}>
<MessageCircle size={13} />
</button>
<button type="button" title="View Ratings" onClick={(e) => { e.stopPropagation(); }} style={actionIconBtn}
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(--card-foreground)"; }}>
<Star size={13} />
</button>
<button type="button" title="Delete" onClick={(e) => { e.stopPropagation(); onDelete(); }} style={{ ...actionIconBtn, color: "var(--destructive)", borderColor: "var(--border)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = "#fef2f2"; (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--card)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; }}>
@ -666,8 +656,6 @@ export function AgendaPage({ embedInDashboard = false }: { embedInDashboard?: bo
} | null>(null);
const [duplicatingTrackId, setDuplicatingTrackId] = useState<string | null>(null);
const [duplicatingSessionId, setDuplicatingSessionId] = useState<string | null>(null);
const [agendaMoreOpen, setAgendaMoreOpen] = useState(false);
const agendaMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!selectedEventId || !openAgendaSessionPanelOnce) return;
@ -769,14 +757,6 @@ export function AgendaPage({ embedInDashboard = false }: { embedInDashboard?: bo
loadAgenda();
}, [loadAgenda]);
useEffect(() => {
const h = (e: MouseEvent) => {
if (agendaMoreRef.current && !agendaMoreRef.current.contains(e.target as Node)) setAgendaMoreOpen(false);
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const toggle = (id: string) =>
setOpenTracks((prev) => {
const n = new Set(prev);
@ -904,6 +884,9 @@ export function AgendaPage({ embedInDashboard = false }: { embedInDashboard?: bo
speakers: spIds.filter(Boolean),
audienceFeedbackEnabled: orig.audienceFeedbackEnabled,
audienceRatingsEnabled: orig.audienceRatingsEnabled,
audienceKeyPersonsEnabled: orig.audienceKeyPersonsEnabled,
audienceMySessionsEnabled: orig.audienceMySessionsEnabled,
audienceAgendaEnabled: orig.audienceAgendaEnabled,
audienceFeedbackForm: orig.audienceFeedbackForm ?? null,
audienceRatingsForm: orig.audienceRatingsForm ?? null,
});
@ -950,70 +933,20 @@ export function AgendaPage({ embedInDashboard = false }: { embedInDashboard?: bo
{loading ? "…" : eventName}
</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: isMobile ? "var(--text-xl)" : "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: "1.2", marginBottom: "4px" }}>
{embedInDashboard ? "Agenda" : "Dashboard"}
{embedInDashboard ? "Agenda" : "Event Home"}
</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
{loadErr ? <span style={{ color: "var(--destructive)" }}>{loadErr}</span> : `${tracks.filter((t) => t.id !== UNASSIGNED_TRACK_ID).length} tracks · ${total} sessions`}
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
{/* Desktop secondary actions */}
{!isSmall && (<>
<button
style={{ display: "flex", alignItems: "center", gap: "6px", 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" }}
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(--card-foreground)"; }}>
<MessageCircle size={14} /> Feedback
</button>
<button
style={{ display: "flex", alignItems: "center", gap: "6px", 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" }}
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(--card-foreground)"; }}>
<Star size={14} /> Ratings
</button>
<div style={{ width: "1px", height: "20px", background: "var(--border)", flexShrink: 0 }} />
<button
onClick={openTrackPanelCreate}
style={{ display: "flex", alignItems: "center", gap: "6px", 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" }}
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(--card-foreground)"; }}
>
<Plus size={14} /> Add Track
</button>
</>)}
{/* Mobile: overflow menu */}
{isSmall && (
<div ref={agendaMoreRef} style={{ position: "relative" }}>
<button
onClick={() => setAgendaMoreOpen(v => !v)}
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "36px", height: "36px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", cursor: "pointer" }}
>···</button>
{agendaMoreOpen && (
<div style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", boxShadow: "var(--elevation-sm)", zIndex: 50, minWidth: "160px", overflow: "hidden" }}>
{[
{ label: "Feedback", icon: <MessageCircle size={13} />, action: () => setAgendaMoreOpen(false) },
{ label: "Ratings", icon: <Star size={13} />, action: () => setAgendaMoreOpen(false) },
{ label: "Add Track", icon: <Plus size={13} />, action: () => { openTrackPanelCreate(); setAgendaMoreOpen(false); } },
].map((item, i, arr) => (
<button key={item.label} onClick={item.action}
style={{ width: "100%", display: "flex", alignItems: "center", gap: "8px", padding: "10px 14px", background: "none", border: "none", cursor: "pointer", textAlign: "left", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", borderBottom: i < arr.length - 1 ? "1px solid var(--border)" : "none" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}>
{item.icon} {item.label}
</button>
))}
</div>
)}
</div>
)}
{/* Primary CTA */}
<button
onClick={() => setSessionPanel({})}
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 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)" }}
onClick={openTrackPanelCreate}
style={{ display: "flex", alignItems: "center", gap: "6px", 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" }}
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(--card-foreground)"; }}
>
<Plus size={14} /> {isSmall ? "New Session" : "Create New Session"}
<Plus size={14} /> {isSmall ? "Track" : "Add Track"}
</button>
</div>
</div>

View File

@ -209,6 +209,15 @@ export function AgendaSessionPanel({
const [audienceRatingsEnabled, setAudienceRatingsEnabled] = useState(() =>
session ? audienceOn(session.audienceRatingsEnabled) : true,
);
const [audienceKeyPersonsEnabled, setAudienceKeyPersonsEnabled] = useState(() =>
session ? audienceOn(session.audienceKeyPersonsEnabled) : true,
);
const [audienceMySessionsEnabled, setAudienceMySessionsEnabled] = useState(() =>
session ? audienceOn(session.audienceMySessionsEnabled) : true,
);
const [audienceAgendaEnabled, setAudienceAgendaEnabled] = useState(() =>
session ? audienceOn(session.audienceAgendaEnabled) : true,
);
const [audienceFeedbackFormId, setAudienceFeedbackFormId] = useState(() =>
sessionLinkedFormId(session ?? null, "audienceFeedbackForm"),
);
@ -257,7 +266,16 @@ export function AgendaSessionPanel({
formsApi
.list({ organization: oid })
.then((list) => {
if (!cancelled) setFeedbackFormsCatalog(list.filter((f) => f.type === "feedback"));
if (!cancelled)
setFeedbackFormsCatalog(
list.filter(
(f) =>
f.type === "feedback" &&
!["Give us your feedback", "Rate us", "EventBuzz Default Feedback", "EventBuzz Default Ratings"].includes(
f.name ?? "",
),
),
);
})
.catch(() => {
if (!cancelled) setFeedbackFormsCatalog([]);
@ -418,6 +436,9 @@ export function AgendaSessionPanel({
speakers: selectedSpeakerIds,
audienceFeedbackEnabled,
audienceRatingsEnabled,
audienceKeyPersonsEnabled,
audienceMySessionsEnabled,
audienceAgendaEnabled,
audienceFeedbackForm: audienceFeedbackEnabled ? audienceFeedbackFormId.trim() || null : null,
audienceRatingsForm: audienceRatingsEnabled ? audienceRatingsFormId.trim() || null : null,
};
@ -973,8 +994,50 @@ export function AgendaSessionPanel({
Attendee session page
</p>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)", lineHeight: 1.45 }}>
Controls whether this session&apos;s mobile page shows quick links to feedback and ratings. The event must also have those features turned on in registration settings.
Controls what appears on this session&apos;s mobile page for attendees.
</p>
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
checked={audienceKeyPersonsEnabled}
onChange={(e) => setAudienceKeyPersonsEnabled(e.target.checked)}
style={{ marginTop: "3px", width: "16px", height: "16px", flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontWeight: "var(--font-weight-medium)", color: "var(--card-foreground)" }}>Key Persons</span>
<span style={{ display: "block", fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: "2px" }}>
Show speakers and moderators for this session.
</span>
</span>
</label>
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
checked={audienceMySessionsEnabled}
onChange={(e) => setAudienceMySessionsEnabled(e.target.checked)}
style={{ marginTop: "3px", width: "16px", height: "16px", flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontWeight: "var(--font-weight-medium)", color: "var(--card-foreground)" }}>My Sessions</span>
<span style={{ display: "block", fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: "2px" }}>
Show bookmarked sessions for this event.
</span>
</span>
</label>
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
checked={audienceAgendaEnabled}
onChange={(e) => setAudienceAgendaEnabled(e.target.checked)}
style={{ marginTop: "3px", width: "16px", height: "16px", flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontWeight: "var(--font-weight-medium)", color: "var(--card-foreground)" }}>Agenda</span>
<span style={{ display: "block", fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: "2px" }}>
Show the full event agenda schedule.
</span>
</span>
</label>
<label style={{ display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
<input
type="checkbox"

View File

@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useState } from "react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { eventsApi } from "../../../services/api/events";
import type { Event, EventDashboardAnalytics } from "../../../services/api/types";
import { StatsBar } from "../dashboard/StatsBar";
import { ChartsSection } from "../dashboard/ChartsSection";
import { FeatureCards } from "../dashboard/FeatureCards";
export function EventAnalyticsPage({
eventId,
onNavigate,
}: {
eventId: string | null;
onNavigate?: (navId: string) => void;
}) {
const bp = useBreakpoint();
const [event, setEvent] = useState<Event | null>(null);
const [analytics, setAnalytics] = useState<EventDashboardAnalytics | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!eventId) {
setEvent(null);
setAnalytics(null);
setError(null);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const [evRes, anRes] = await Promise.all([eventsApi.get(eventId), eventsApi.analytics(eventId)]);
if (evRes.error) {
setError(typeof evRes.error === "string" ? evRes.error : "Failed to load event.");
setEvent(null);
setAnalytics(null);
return;
}
setEvent(evRes.data ?? null);
if (anRes.error) {
setError(typeof anRes.error === "string" ? anRes.error : "Failed to load analytics.");
setAnalytics(null);
return;
}
setAnalytics(anRes.data ?? null);
} catch {
setError("Failed to load analytics.");
setAnalytics(null);
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
const handleNavigate = (navId: string) => {
if (navId === "agenda") {
onNavigate?.("dashboard");
return;
}
onNavigate?.(navId);
};
if (!eventId) {
return (
<div style={{ padding: "24px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontSize: "var(--text-sm)" }}>
Select an event to view analytics.
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "4px" }}>
{loading && !event ? "Loading…" : event?.name ?? "Event"}
</p>
<h1
style={{
fontFamily: "var(--font-inter)",
fontSize: bp === "mobile" ? "var(--text-xl)" : "var(--text-2xl)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
lineHeight: "1.2",
}}
>
Analytics
</h1>
{error ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", marginTop: "8px" }}>{error}</p>
) : null}
</div>
<StatsBar
loading={loading}
participantCount={analytics?.participantCount}
sessionCount={analytics?.sessionCount}
quizPollCount={analytics?.quizPollCount}
pointsAwarded={analytics?.pointsAwarded}
pointsRedeemed={analytics?.pointsRedeemed}
statusCounts={analytics?.statusCounts}
upcomingSessionCount={analytics?.upcomingSessionCount}
/>
<div>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
fontWeight: "var(--font-weight-medium)",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "10px",
}}
>
Charts
</p>
<ChartsSection
loading={loading}
registrationTrend={analytics?.registrationTrend}
quizStats={analytics?.quizStats}
participantBreakdown={analytics?.participantBreakdown}
participantBreakdownSource={analytics?.participantBreakdownSource}
/>
</div>
<div>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
fontWeight: "var(--font-weight-medium)",
textTransform: "uppercase",
letterSpacing: "0.06em",
marginBottom: "10px",
}}
>
Features
</p>
<FeatureCards loading={loading} analytics={analytics} onNavigate={onNavigate ? handleNavigate : undefined} />
</div>
<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>
</div>
);
}

View File

@ -1,30 +1,12 @@
import React from "react";
import React, { useMemo } from "react";
import {
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from "recharts";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import type { EventDashboardAnalytics } from "../../../services/api/types";
const registrationData = [
{ week: "Wk 1", Registrations: 11, "Check-ins": 0 },
{ week: "Wk 2", Registrations: 18, "Check-ins": 0 },
{ week: "Wk 3", Registrations: 25, "Check-ins": 8 },
{ week: "Wk 4", Registrations: 26, "Check-ins": 18 },
];
const quizData = [
{ name: "Pulmonary", Responses: 22, "Avg Score": 82 },
{ name: "ICS Guide", Responses: 17, "Avg Score": 65 },
{ name: "COPD", Responses: 24, "Avg Score": 90 },
{ name: "Resp.Phy", Responses: 12, "Avg Score": 45 },
];
const roleData = [
{ name: "Delegates", value: 18 },
{ name: "Speakers", value: 3 },
{ name: "Moderators",value: 2 },
{ name: "Panelists", value: 2 },
{ name: "Admins", value: 1 },
];
const COLORS = ["var(--chart-1)","var(--chart-2)","var(--chart-3)","var(--chart-4)","var(--chart-5)"];
const COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
const tip = { background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", fontSize: "12px", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", boxShadow: "var(--elevation-sm)" };
const tickStyle = { fontSize: 11, fontFamily: "var(--font-noto-sans)", fill: "var(--muted)" };
@ -34,79 +16,149 @@ function ChartCard({ title, subtitle, children }: { title: string; subtitle?: st
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "20px" }}>
<div style={{ marginBottom: "16px" }}>
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: "1.3" }}>{title}</p>
{subtitle && <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: "2px" }}>{subtitle}</p>}
{subtitle ? <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: "2px" }}>{subtitle}</p> : null}
</div>
{children}
</div>
);
}
export function ChartsSection() {
function ChartEmpty({ message }: { message: string }) {
return (
<div style={{ height: 160, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", padding: "0 16px" }}>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", margin: 0 }}>{message}</p>
</div>
);
}
function truncateLabel(s: string, max = 14): string {
const t = s.trim();
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
}
export function ChartsSection({
loading,
registrationTrend = [],
quizStats = [],
participantBreakdown = [],
participantBreakdownSource = "status",
}: {
loading?: boolean;
registrationTrend?: EventDashboardAnalytics["registrationTrend"];
quizStats?: EventDashboardAnalytics["quizStats"];
participantBreakdown?: EventDashboardAnalytics["participantBreakdown"];
participantBreakdownSource?: EventDashboardAnalytics["participantBreakdownSource"];
}) {
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isTablet = bp === "tablet";
// Mobile: 1 col, Tablet: 2 col (pie chart spans full), Desktop: 3 col
const cols = isMobile ? 1 : isTablet ? 2 : 3;
const chartH = isMobile ? 160 : 180;
const registrationData = useMemo(
() =>
(registrationTrend ?? []).map((row) => ({
period: row.date,
Registrations: row.registrations,
"Check-ins": row.checkIns,
})),
[registrationTrend],
);
const quizData = useMemo(
() =>
(quizStats ?? []).slice(0, 8).map((q) => ({
name: truncateLabel(q.title),
Responses: q.responses,
"Avg Score": q.avgScore ?? 0,
})),
[quizStats],
);
const roleData = useMemo(
() => (participantBreakdown ?? []).filter((r) => r.count > 0).map((r) => ({ name: r.label, value: r.count })),
[participantBreakdown],
);
const hasRegistration = registrationData.some((r) => r.Registrations > 0 || r["Check-ins"] > 0);
const hasQuiz = quizData.some((q) => q.Responses > 0);
const hasBreakdown = roleData.length > 0;
const pieTitle = participantBreakdownSource === "tags" ? "Participants by tag" : "Participants by status";
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: isMobile ? "12px" : "16px" }}>
{/* Registration Trend */}
<ChartCard title="Registration Trend" subtitle="Weekly registrations vs check-ins">
<ResponsiveContainer width="100%" height={chartH}>
<LineChart data={registrationData} margin={{ top: 4, right: 8, left: -24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="week" tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tip} />
<Legend iconType="circle" iconSize={7} wrapperStyle={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)" }} />
<Line type="monotone" dataKey="Registrations" stroke="var(--chart-2)" strokeWidth={2} dot={{ r: 3, fill: "var(--chart-2)", strokeWidth: 0 }} activeDot={{ r: 5 }} />
<Line type="monotone" dataKey="Check-ins" stroke="var(--chart-1)" strokeWidth={2} dot={{ r: 3, fill: "var(--chart-1)", strokeWidth: 0 }} activeDot={{ r: 5 }} strokeDasharray="4 3" />
</LineChart>
</ResponsiveContainer>
<ChartCard title="Registration Trend" subtitle="Registrations vs check-ins over time">
{loading ? (
<ChartEmpty message="Loading…" />
) : !hasRegistration ? (
<ChartEmpty message="No registrations yet." />
) : (
<ResponsiveContainer width="100%" height={chartH}>
<LineChart data={registrationData} margin={{ top: 4, right: 8, left: -24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="period" tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tip} />
<Legend iconType="circle" iconSize={7} wrapperStyle={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)" }} />
<Line type="monotone" dataKey="Registrations" stroke="var(--chart-2)" strokeWidth={2} dot={{ r: 3, fill: "var(--chart-2)", strokeWidth: 0 }} activeDot={{ r: 5 }} />
<Line type="monotone" dataKey="Check-ins" stroke="var(--chart-1)" strokeWidth={2} dot={{ r: 3, fill: "var(--chart-1)", strokeWidth: 0 }} activeDot={{ r: 5 }} strokeDasharray="4 3" />
</LineChart>
</ResponsiveContainer>
)}
</ChartCard>
{/* Quiz Participation */}
<ChartCard title="Quiz Participation" subtitle="Responses and average scores">
<ResponsiveContainer width="100%" height={chartH}>
<BarChart data={quizData} margin={{ top: 4, right: 8, left: -24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="name" tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tip} />
<Legend iconType="square" iconSize={7} wrapperStyle={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)" }} />
<Bar dataKey="Responses" fill="var(--chart-3)" radius={[3, 3, 0, 0]} />
<Bar dataKey="Avg Score" fill="var(--chart-4)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
{loading ? (
<ChartEmpty message="Loading…" />
) : !hasQuiz ? (
<ChartEmpty message="No quiz responses yet." />
) : (
<ResponsiveContainer width="100%" height={chartH}>
<BarChart data={quizData} margin={{ top: 4, right: 8, left: -24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="name" tick={tickStyle} axisLine={false} tickLine={false} />
<YAxis tick={tickStyle} axisLine={false} tickLine={false} />
<Tooltip contentStyle={tip} />
<Legend iconType="square" iconSize={7} wrapperStyle={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)" }} />
<Bar dataKey="Responses" fill="var(--chart-3)" radius={[3, 3, 0, 0]} />
<Bar dataKey="Avg Score" fill="var(--chart-4)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</ChartCard>
{/* Participant Roles — spans full width on tablet */}
<div style={isTablet ? { gridColumn: "1 / -1" } : {}}>
<ChartCard title="Participant Roles" subtitle="Breakdown by role type">
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<ResponsiveContainer width={isMobile ? "45%" : "50%"} height={isMobile ? 140 : 160}>
<PieChart>
<Pie data={roleData} cx="50%" cy="50%" innerRadius={isMobile ? 36 : 44} outerRadius={isMobile ? 60 : 72} paddingAngle={2} dataKey="value">
{roleData.map((entry, i) => <Cell key={`cell-${entry.name}`} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip contentStyle={tip} />
</PieChart>
</ResponsiveContainer>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "7px" }}>
{roleData.map((e, i) => (
<div key={`legend-${e.name}`} style={{ display: "flex", alignItems: "center", gap: "7px" }}>
<span style={{ width: "8px", height: "8px", borderRadius: "50%", background: COLORS[i % COLORS.length], flexShrink: 0 }} />
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", flex: 1 }}>{e.name}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontWeight: "var(--font-weight-medium)" }}>{e.value}</span>
</div>
))}
<ChartCard title={pieTitle} subtitle="Breakdown of registered participants">
{loading ? (
<ChartEmpty message="Loading…" />
) : !hasBreakdown ? (
<ChartEmpty message="No participants to show yet." />
) : (
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<ResponsiveContainer width={isMobile ? "45%" : "50%"} height={isMobile ? 140 : 160}>
<PieChart>
<Pie data={roleData} cx="50%" cy="50%" innerRadius={isMobile ? 36 : 44} outerRadius={isMobile ? 60 : 72} paddingAngle={2} dataKey="value">
{roleData.map((entry, i) => (
<Cell key={`cell-${entry.name}`} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={tip} />
</PieChart>
</ResponsiveContainer>
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: "7px" }}>
{roleData.map((e, i) => (
<div key={`legend-${e.name}`} style={{ display: "flex", alignItems: "center", gap: "7px" }}>
<span style={{ width: "8px", height: "8px", borderRadius: "50%", background: COLORS[i % COLORS.length], flexShrink: 0 }} />
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", flex: 1 }}>{e.name}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontWeight: "var(--font-weight-medium)" }}>{e.value}</span>
</div>
))}
</div>
</div>
</div>
)}
</ChartCard>
</div>
</div>
);
}
}

View File

@ -0,0 +1,355 @@
import React, { useCallback, useEffect, useState } from "react";
import { Copy, Download, Plus, Tag, X } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import { badgeBatchesApi, type BadgeBatchRecord } from "../../../services/api/badgeBatches";
function formatRequestedAt(createdAt?: string): string {
if (!createdAt) return "";
const d = new Date(createdAt);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function statusLabel(status: BadgeBatchRecord["status"]): string {
if (status === "ready") return "Ready";
if (status === "processing") return "Processing";
if (status === "failed") return "Failed";
return "Pending";
}
export function EventBadgesPanel({
eventId,
eventName,
onClose,
onAddBadge,
onDuplicateBadge,
}: {
eventId: string;
eventName?: string;
onClose: () => void;
onAddBadge: () => void;
onDuplicateBadge: (batchId: string) => void;
}) {
const { user } = useAuth();
const canManage = Boolean(user && ["super_admin", "admin", "moderator"].includes(user.role));
const [badges, setBadges] = useState<BadgeBatchRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const loadBadges = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await badgeBatchesApi.list({ event: eventId, limit: 100 });
if (res.error) {
setError(typeof res.error === "string" ? res.error : "Failed to load badges.");
setBadges([]);
} else {
setBadges(res.data?.batches ?? []);
}
} catch {
setError("Failed to load badges.");
setBadges([]);
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void loadBadges();
}, [loadBadges]);
return (
<>
<div
onClick={onClose}
role="presentation"
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 200 }}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="event-badges-title"
style={{
position: "fixed",
top: 0,
right: 0,
height: "100%",
width: "min(720px, 100vw)",
background: "var(--card)",
zIndex: 201,
display: "flex",
flexDirection: "column",
boxShadow: "-4px 0 40px rgba(0,0,0,0.2)",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
padding: "16px 18px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ minWidth: 0 }}>
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
margin: "0 0 4px",
}}
>
Tag & Badge Configuration
</p>
<h2
id="event-badges-title"
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-lg)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
lineHeight: 1.3,
}}
>
{eventName ? `Badges · ${eventName}` : "Badges"}
</h2>
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
{canManage ? (
<button
type="button"
onClick={onAddBadge}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: "pointer",
}}
>
<Plus size={14} /> Add Badge
</button>
) : null}
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 36,
height: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--muted)",
cursor: "pointer",
}}
>
<X size={18} />
</button>
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 18px" }}>
{loading ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Loading badges…
</p>
) : null}
{error ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)" }}>
{error}
</p>
) : null}
{!loading && !error && badges.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
padding: "48px 24px",
gap: 12,
}}
>
<Tag size={36} style={{ color: "var(--border)" }} />
<p
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-base)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
}}
>
No badges yet
</p>
<p
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
margin: 0,
maxWidth: 320,
lineHeight: 1.5,
}}
>
Create a badge export to design attendee and speaker badges for this event.
</p>
{canManage ? (
<button
type="button"
onClick={onAddBadge}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
marginTop: 8,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--primary)",
color: "var(--primary-foreground)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: "pointer",
}}
>
<Plus size={14} /> Add Badge
</button>
) : null}
</div>
) : null}
{!loading && !error && badges.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{badges.map((b) => (
<div
key={b._id}
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Tag size={18} 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)" }}>
Status: <strong style={{ color: "var(--foreground)" }}>{statusLabel(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>
) : null}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{canManage ? (
<button
type="button"
title="Duplicate — copy settings and edit"
onClick={() => onDuplicateBadge(b._id)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
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>
) : null}
{b.status === "ready" && b.zipUrl ? (
<a
href={b.zipUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
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: 6,
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 {formatRequestedAt(b.createdAt)}
</span>
) : null}
</div>
))}
</div>
) : null}
</div>
</div>
</>
);
}

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import { Calendar, MapPin, ExternalLink, Pencil } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import type { Event } from "../../../services/api/types";
import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl";
const TYPE_LABELS: Record<string, string> = {
conference: "Conference",
@ -58,13 +59,6 @@ function resolveBannerUrl(event: Event): string | null {
return null;
}
function micrositePublicPageUrl(folder: string): string | null {
const base = (import.meta.env.VITE_MICROSITE_PUBLIC_BASE as string | undefined)?.replace(/\/+$/, "");
if (!base || !folder.trim()) return null;
const slug = folder.replace(/^\/+|\/+$/g, "");
return `${base}/${slug}/index.html`;
}
export function EventHeader({
event,
loading = false,
@ -98,7 +92,11 @@ export function EventHeader({
? [event.location?.trim(), event.virtualLink?.trim()].filter(Boolean).join(" · ") || "Hybrid"
: event?.location?.trim() || "—";
const viewHref = event?.website?.folder ? micrositePublicPageUrl(event.website.folder) : null;
const viewHref = event?.website?.folder
? micrositePublicPageUrl(event.website.folder)
: event?._id
? defaultMicrositePageUrl(event._id)
: null;
const statusIndicator =
event?.status === "live"

View File

@ -0,0 +1,169 @@
import React, { useEffect, useState } from "react";
import { QrCode, Radio } from "lucide-react";
import { AgendaQrModal } from "../agenda/AgendaQrModal";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext";
import { eventsApi } from "../../../services/api/events";
import type { Event } from "../../../services/api/types";
const headerBtnBase: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "6px",
padding: "7px 14px",
borderRadius: "var(--radius-button)",
borderWidth: "1px",
borderStyle: "solid",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
cursor: "pointer",
transition: "all 0.15s",
};
export function EventHomeHeaderActions() {
const { selectedEventId } = useAppNav();
const bp = useBreakpoint();
const isSmall = bp === "mobile" || bp === "tablet";
const [qrOpen, setQrOpen] = useState(false);
const [liveActive, setLiveActive] = useState(false);
const [qrEvent, setQrEvent] = useState<Event | null | undefined>(undefined);
useEffect(() => {
if (!qrOpen || !selectedEventId) {
setQrEvent(undefined);
return;
}
setQrEvent(undefined);
let cancelled = false;
void eventsApi.get(selectedEventId).then((res) => {
if (!cancelled) setQrEvent(res.data ?? null);
});
return () => {
cancelled = true;
};
}, [qrOpen, selectedEventId]);
function openQr() {
if (!selectedEventId) {
window.alert("Select an event from the sidebar first.");
return;
}
setQrOpen(true);
}
function toggleLive() {
if (!liveActive && !window.confirm("Start broadcasting this event as Live? All participants will be notified.")) {
return;
}
setLiveActive((v) => !v);
}
return (
<>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<button
type="button"
onClick={openQr}
disabled={!selectedEventId}
style={{
...headerBtnBase,
borderColor: "var(--border)",
background: "var(--card)",
color: "var(--card-foreground)",
opacity: selectedEventId ? 1 : 0.55,
cursor: selectedEventId ? "pointer" : "not-allowed",
}}
onMouseEnter={(e) => {
if (!selectedEventId) return;
(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(--card-foreground)";
}}
>
<QrCode size={14} />
{isSmall ? "QR" : "Generate QR Code"}
</button>
<button
type="button"
onClick={toggleLive}
style={{
...headerBtnBase,
borderColor: liveActive ? "var(--destructive)" : "var(--border)",
background: liveActive ? "var(--destructive)" : "var(--card)",
color: liveActive ? "var(--destructive-foreground)" : "var(--card-foreground)",
}}
onMouseEnter={(e) => {
if (liveActive) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (liveActive) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}}
>
{liveActive ? (
<span
style={{
width: "7px",
height: "7px",
borderRadius: "50%",
background: "var(--destructive-foreground)",
display: "block",
}}
/>
) : (
<Radio size={14} />
)}
{liveActive ? "Stop Live" : isSmall ? "Go Live" : "Go Live"}
</button>
</div>
{qrOpen && selectedEventId && qrEvent !== undefined ? (
<AgendaQrModal
open
onClose={() => setQrOpen(false)}
kind="event"
joinCode={(qrEvent?.joinCode ?? "").trim()}
label={qrEvent?.name ?? "Event"}
/>
) : null}
{qrOpen && selectedEventId && qrEvent === undefined ? (
<div
role="status"
aria-live="polite"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
zIndex: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "16px",
}}
>
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
padding: "24px 32px",
border: "1px solid var(--border)",
fontFamily: "var(--font-noto-sans)",
fontSize: "var(--text-sm)",
color: "var(--muted)",
}}
>
Loading event…
</div>
</div>
) : null}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
import React, { useState } from "react";
import {
HelpCircle, BarChart2, Trophy, Globe, CalendarDays, Users,
HelpCircle, BarChart2, Trophy, CalendarDays, Users,
ChevronRight, CheckCircle2, Clock,
} from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import type { EventDashboardAnalytics } from "../../../services/api/types";
/* ─── Shared ─── */
function Card({ children }: { children: React.ReactNode }) {
return (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "20px", display: "flex", flexDirection: "column", gap: "16px" }}>
@ -13,21 +13,39 @@ function Card({ children }: { children: React.ReactNode }) {
</div>
);
}
function CardHeader({ icon, title, color }: { icon: React.ReactNode; title: string; color: string }) {
function CardHeader({
icon,
title,
color,
onViewAll,
}: {
icon: React.ReactNode;
title: string;
color: string;
onViewAll?: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{ width: "28px", height: "28px", borderRadius: "var(--radius-button)", background: color + "15", display: "flex", alignItems: "center", justifyContent: "center", color }}>{icon}</div>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{title}</span>
</div>
<button style={{ display: "flex", alignItems: "center", gap: "3px", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", padding: "2px 4px" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
View all <ChevronRight size={12} />
</button>
{onViewAll ? (
<button
type="button"
onClick={onViewAll}
style={{ display: "flex", alignItems: "center", gap: "3px", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", padding: "2px 4px" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
View all <ChevronRight size={12} />
</button>
) : null}
</div>
);
}
function MiniBar({ label, pct, color }: { label: string; pct: number; color: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
@ -41,6 +59,7 @@ function MiniBar({ label, pct, color }: { label: string; pct: number; color: str
</div>
);
}
function TypePill({ label, bg, color }: { label: string; bg: string; color: string }) {
return (
<span style={{ display: "inline-flex", padding: "2px 8px", borderRadius: "4px", background: bg, color, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", whiteSpace: "nowrap" }}>
@ -48,6 +67,7 @@ function TypePill({ label, bg, color }: { label: string; bg: string; color: stri
</span>
);
}
function StatGrid({ items }: { items: [string, string][] }) {
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${items.length}, 1fr)`, gap: "1px", background: "var(--border)", borderRadius: "var(--radius-button)", overflow: "hidden" }}>
@ -61,170 +81,197 @@ function StatGrid({ items }: { items: [string, string][] }) {
);
}
/* ─── Cards ─── */
function QuizCard() {
return (
<Card>
<CardHeader icon={<HelpCircle size={15} />} title="Quiz" color="var(--chart-3)" />
<StatGrid items={[["4","Active"],["24","Questions"],["71%","Avg Score"]]} />
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<MiniBar label="Pulmonary Anatomy" pct={82} color="var(--chart-3)" />
<MiniBar label="ICS Guidelines 2025" pct={65} color="var(--chart-3)" />
<MiniBar label="COPD Management" pct={90} color="var(--chart-3)" />
<MiniBar label="Respiratory Physiology" pct={45} color="var(--chart-3)" />
</div>
</Card>
);
}
function PollCard() {
const [active, setActive] = useState(0);
const polls = [
{ q: "How would you rate the venue?", votes: 62, options: [{ l: "Excellent", p: 50 }, { l: "Good", p: 31 }, { l: "Average", p: 14 }, { l: "Poor", p: 5 }] },
{ q: "Best session of the day?", votes: 48, options: [{ l: "Keynote Address", p: 46 }, { l: "COPD Workshop", p: 29 }, { l: "Panel Discussion", p: 25 }] },
];
const poll = polls[active];
return (
<Card>
<CardHeader icon={<BarChart2 size={15} />} title="Polls" color="var(--chart-4)" />
<StatGrid items={[["6","Active Polls"],["342","Total Votes"]]} />
<div style={{ display: "flex", gap: "6px" }}>
{polls.map((_, i) => (
<button key={i} onClick={() => setActive(i)}
style={{ flex: 1, padding: "5px 0", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: active === i ? "var(--primary)" : "var(--card)", color: active === i ? "#fff" : "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
Poll {i + 1}
</button>
))}
</div>
<div>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "3px" }}>{poll.q}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "10px" }}>{poll.votes} votes</p>
<div style={{ display: "flex", flexDirection: "column", gap: "7px" }}>
{poll.options.map(o => <MiniBar key={o.l} label={o.l} pct={o.p} color="var(--chart-4)" />)}
</div>
</div>
</Card>
);
}
function RewardCard() {
const rows = [
{ rank: "🥇", name: "Dr. Rajesh Sharma", pts: 240 },
{ rank: "🥈", name: "Dr. Priya Mehta", pts: 210 },
{ rank: "🥉", name: "Dr. Anil Kumar", pts: 185 },
{ rank: "4", name: "Dr. Sunita Patel", pts: 160 },
{ rank: "5", name: "Dr. Vikram Singh", pts: 145 },
{ rank: "6", name: "Dr. Neha Gupta", pts: 130 },
];
return (
<Card>
<CardHeader icon={<Trophy size={15} />} title="Reward Points" color="var(--chart-2)" />
<StatGrid items={[["1,240","Total Points"],["18","Earners"]]} />
<div style={{ display: "flex", flexDirection: "column" }}>
{rows.map((r, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "7px 0", borderBottom: i < rows.length - 1 ? "1px solid var(--border)" : "none" }}>
<span style={{ fontSize: "var(--text-sm)", width: "20px", textAlign: "center", flexShrink: 0 }}>{r.rank}</span>
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{r.name}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: i < 3 ? "#b45309" : "var(--muted)", background: i < 3 ? "#fef9c3" : "var(--secondary)", padding: "2px 8px", borderRadius: "99px" }}>{r.pts}</span>
</div>
))}
</div>
</Card>
);
}
function WebsiteCard() {
const pages = [
{ name: "Home", status: "Published", views: 842 },
{ name: "Agenda", status: "Published", views: 534 },
{ name: "Speakers", status: "Published", views: 298 },
{ name: "Registration", status: "Draft", views: 0 },
{ name: "Gallery", status: "Draft", views: 0 },
];
return (
<Card>
<CardHeader icon={<Globe size={15} />} title="Event Website" color="var(--chart-1)" />
<StatGrid items={[["1,842","Total Views"],["5","Pages"],["Live","Status"]]} />
<div style={{ display: "flex", flexDirection: "column" }}>
{pages.map((page, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "7px 0", borderBottom: i < pages.length - 1 ? "1px solid var(--border)" : "none" }}>
<span style={{ flex: 1, fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{page.name}</span>
<TypePill label={page.status} bg={page.status === "Published" ? "#dcfce7" : "#f3f4f6"} color={page.status === "Published" ? "#15803d" : "#6b7280"} />
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", minWidth: "28px", textAlign: "right" }}>{page.views > 0 ? page.views : "—"}</span>
</div>
))}
</div>
</Card>
);
}
function AgendaCard() {
const sessions = [
{ time: "09:00", title: "Opening Keynote", track: "Plenary", typeColor: "#5b21b6", typeBg: "#ede9fe" },
{ time: "10:30", title: "COPD Management Guidelines 2025", track: "Clinical", typeColor: "#1d4ed8", typeBg: "#dbeafe" },
{ time: "12:00", title: "Lunch & Networking Break", track: "Break", typeColor: "#6b7280", typeBg: "#f3f4f6" },
{ time: "13:30", title: "Interstitial Lung Disease Workshop", track: "Workshop", typeColor: "#15803d", typeBg: "#dcfce7" },
{ time: "15:00", title: "Panel: Future of Respiratory Medicine",track: "Panel", typeColor: "#b91c1c", typeBg: "#fee2e2" },
];
return (
<Card>
<CardHeader icon={<CalendarDays size={15} />} title="Agenda" color="var(--chart-5)" />
<StatGrid items={[["12","Sessions"],["4","Days"],["8","Speakers"]]} />
<div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontWeight: "var(--font-weight-medium)", marginBottom: "8px", textTransform: "uppercase", letterSpacing: "0.05em" }}>Today</p>
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
{sessions.map((s, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "6px 0", borderBottom: i < sessions.length - 1 ? "1px solid var(--border)" : "none" }}>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", flexShrink: 0, width: "36px" }}>{s.time}</span>
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", lineHeight: "1.3" }}>{s.title}</span>
<TypePill label={s.track} bg={s.typeBg} color={s.typeColor} />
</div>
))}
</div>
</div>
</Card>
);
}
function ParticipantsCard() {
const recent = [
{ name: "Dr. Anika Sharma", role: "Delegate", checked: true, time: "10m" },
{ name: "Prof. Ravi Kumar", role: "Speaker", checked: true, time: "25m" },
{ name: "Dr. Meena Patel", role: "Moderator", checked: true, time: "1h" },
{ name: "Dr. Sanjay Gupta", role: "Panelist", checked: false, time: "2h" },
{ name: "Dr. Pooja Singh", role: "Delegate", checked: false, time: "3h" },
];
return (
<Card>
<CardHeader icon={<Users size={15} />} title="Participants" color="var(--chart-1)" />
<StatGrid items={[["26","Registered"],["18","Checked In"],["69%","Rate"]]} />
<div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontWeight: "var(--font-weight-medium)", marginBottom: "8px", textTransform: "uppercase", letterSpacing: "0.05em" }}>Recent Activity</p>
<div style={{ display: "flex", flexDirection: "column" }}>
{recent.map((p, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "7px 0", borderBottom: i < recent.length - 1 ? "1px solid var(--border)" : "none" }}>
<div style={{ width: "26px", height: "26px", borderRadius: "50%", background: "var(--accent)", color: "var(--accent-foreground)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "var(--text-xs)", fontFamily: "var(--font-inter)", flexShrink: 0 }}>
{p.name.split(" ").slice(1).join("")[0]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--card-foreground)", lineHeight: "1.3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{p.role} · {p.time} ago</p>
</div>
{p.checked ? <CheckCircle2 size={14} style={{ color: "#16a34a", flexShrink: 0 }} /> : <Clock size={14} style={{ color: "var(--muted)", flexShrink: 0 }} />}
</div>
))}
</div>
</div>
</Card>
);
function displayLeaderboardName(row: { firstName?: string; lastName?: string; email?: string }): string {
const n = [row.firstName, row.lastName].filter(Boolean).join(" ").trim();
return n || row.email || "Participant";
}
export function FeatureCards() {
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 48) return `${hrs}h`;
const days = Math.floor(hrs / 24);
return `${days}d`;
}
function trackPillStyle(track: string): { bg: string; color: string } {
const t = track.toLowerCase();
if (t.includes("keynote") || t.includes("plenary")) return { bg: "#ede9fe", color: "#5b21b6" };
if (t.includes("workshop")) return { bg: "#dcfce7", color: "#15803d" };
if (t.includes("panel")) return { bg: "#fee2e2", color: "#b91c1c" };
if (t.includes("break")) return { bg: "#f3f4f6", color: "#6b7280" };
return { bg: "#dbeafe", color: "#1d4ed8" };
}
export function FeatureCards({
loading,
analytics,
onNavigate,
}: {
loading?: boolean;
analytics: EventDashboardAnalytics | null;
onNavigate?: (navId: string) => void;
}) {
const bp = useBreakpoint();
const cols = bp === "mobile" ? 1 : bp === "tablet" ? 2 : 3;
const quizStats = analytics?.quizStats ?? [];
const pollSummaries = analytics?.pollSummaries ?? [];
const leaderboard = analytics?.leaderboardTop ?? [];
const agenda = analytics?.agendaSummary;
const recent = analytics?.recentParticipants ?? [];
const activeQuizzes = quizStats.filter((q) => q.responses > 0);
const totalQuestions = quizStats.reduce((s, q) => s + q.questionCount, 0);
const avgScore =
activeQuizzes.length > 0
? Math.round(activeQuizzes.reduce((s, q) => s + (q.avgScore ?? 0), 0) / activeQuizzes.length)
: 0;
const checkedIn = analytics?.statusCounts?.checked_in ?? 0;
const registered = analytics?.participantCount ?? 0;
const checkInRate = registered > 0 ? Math.round((checkedIn / registered) * 100) : 0;
const [activePoll, setActivePoll] = useState(0);
const poll = pollSummaries[activePoll] ?? pollSummaries[0];
const dash = loading ? "—" : undefined;
return (
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: bp === "mobile" ? "12px" : "16px" }}>
<QuizCard />
<PollCard />
<RewardCard />
<WebsiteCard />
<AgendaCard />
<ParticipantsCard />
<Card>
<CardHeader icon={<HelpCircle size={15} />} title="Quiz" color="var(--chart-3)" onViewAll={onNavigate ? () => onNavigate("event-activities") : undefined} />
<StatGrid items={[[dash ?? String(quizStats.length), "Active"], [dash ?? String(totalQuestions), "Questions"], [dash ?? (activeQuizzes.length ? `${avgScore}%` : "—"), "Avg Score"]]} />
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{loading ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : quizStats.length === 0 ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>No quizzes yet.</p>
) : (
quizStats.slice(0, 4).map((q) => (
<MiniBar key={q.id} label={q.title} pct={q.avgScore ?? 0} color="var(--chart-3)" />
))
)}
</div>
</Card>
<Card>
<CardHeader icon={<BarChart2 size={15} />} title="Polls" color="var(--chart-4)" onViewAll={onNavigate ? () => onNavigate("event-activities") : undefined} />
<StatGrid items={[[dash ?? String(analytics?.pollCount ?? 0), "Active Polls"], [dash ?? String(pollSummaries.reduce((s, p) => s + p.totalVotes, 0)), "Total Votes"]]} />
{loading ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : !poll ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>No polls yet.</p>
) : (
<>
{pollSummaries.length > 1 ? (
<div style={{ display: "flex", gap: "6px" }}>
{pollSummaries.map((p, i) => (
<button
key={p.id}
type="button"
onClick={() => setActivePoll(i)}
style={{ flex: 1, padding: "5px 0", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: activePoll === i ? "var(--primary)" : "var(--card)", color: activePoll === i ? "#fff" : "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
>
Poll {i + 1}
</button>
))}
</div>
) : null}
<div>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "3px" }}>{poll.title}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "10px" }}>{poll.totalVotes} votes</p>
<div style={{ display: "flex", flexDirection: "column", gap: "7px" }}>
{poll.options.length ? poll.options.map((o) => <MiniBar key={o.label} label={o.label} pct={o.pct} color="var(--chart-4)" />) : (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>No votes recorded yet.</p>
)}
</div>
</div>
</>
)}
</Card>
<Card>
<CardHeader icon={<Trophy size={15} />} title="Reward Points" color="var(--chart-2)" onViewAll={onNavigate ? () => onNavigate("rewards") : undefined} />
<StatGrid items={[[dash ?? (analytics?.pointsAwarded?.toLocaleString() ?? "0"), "Total Points"], [dash ?? String(leaderboard.length), "Earners"]]} />
<div style={{ display: "flex", flexDirection: "column" }}>
{loading ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : leaderboard.length === 0 ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>No leaderboard entries yet.</p>
) : (
leaderboard.slice(0, 6).map((r, i) => {
const medals = ["🥇", "🥈", "🥉"];
const rank = i < 3 ? medals[i] : String(i + 1);
const name = displayLeaderboardName(r);
return (
<div key={String(r.participantId)} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "7px 0", borderBottom: i < Math.min(leaderboard.length, 6) - 1 ? "1px solid var(--border)" : "none" }}>
<span style={{ fontSize: "var(--text-sm)", width: "20px", textAlign: "center", flexShrink: 0 }}>{rank}</span>
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{name}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: i < 3 ? "#b45309" : "var(--muted)", background: i < 3 ? "#fef9c3" : "var(--secondary)", padding: "2px 8px", borderRadius: "99px" }}>{r.total}</span>
</div>
);
})
)}
</div>
</Card>
<Card>
<CardHeader icon={<CalendarDays size={15} />} title="Agenda" color="var(--chart-5)" onViewAll={onNavigate ? () => onNavigate("agenda") : undefined} />
<StatGrid items={[[dash ?? String(agenda?.sessionCount ?? analytics?.sessionCount ?? 0), "Sessions"], [dash ?? String(agenda?.dayCount ?? 0), "Days"], [dash ?? String(agenda?.speakerCount ?? 0), "Speakers"]]} />
<div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontWeight: "var(--font-weight-medium)", marginBottom: "8px", textTransform: "uppercase", letterSpacing: "0.05em" }}>Today</p>
{loading ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : !agenda?.todaySessions?.length ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>No sessions scheduled for today.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
{agenda.todaySessions.map((s, i) => {
const pill = trackPillStyle(s.track);
return (
<div key={s.sessionId} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "6px 0", borderBottom: i < agenda.todaySessions.length - 1 ? "1px solid var(--border)" : "none" }}>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", flexShrink: 0, width: "36px" }}>{s.time}</span>
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", lineHeight: "1.3" }}>{s.title}</span>
<TypePill label={s.track} bg={pill.bg} color={pill.color} />
</div>
);
})}
</div>
)}
</div>
</Card>
<Card>
<CardHeader icon={<Users size={15} />} title="Participants" color="var(--chart-1)" onViewAll={onNavigate ? () => onNavigate("participants") : undefined} />
<StatGrid items={[[dash ?? String(registered), "Registered"], [dash ?? String(checkedIn), "Checked In"], [dash ?? (registered ? `${checkInRate}%` : "—"), "Rate"]]} />
<div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", fontWeight: "var(--font-weight-medium)", marginBottom: "8px", textTransform: "uppercase", letterSpacing: "0.05em" }}>Recent Activity</p>
{loading ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : recent.length === 0 ? (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>No recent activity.</p>
) : (
<div style={{ display: "flex", flexDirection: "column" }}>
{recent.map((p, i) => (
<div key={p.id} style={{ display: "flex", alignItems: "center", gap: "10px", padding: "7px 0", borderBottom: i < recent.length - 1 ? "1px solid var(--border)" : "none" }}>
<div style={{ width: "26px", height: "26px", borderRadius: "50%", background: "var(--accent)", color: "var(--accent-foreground)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "var(--text-xs)", fontFamily: "var(--font-inter)", flexShrink: 0 }}>
{p.name.slice(0, 1).toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: "1.3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{p.status} · {timeAgo(p.activityAt)} ago</p>
</div>
{p.checkedIn ? <CheckCircle2 size={14} style={{ color: "#16a34a", flexShrink: 0 }} /> : <Clock size={14} style={{ color: "var(--muted)", flexShrink: 0 }} />}
</div>
))}
</div>
)}
</div>
</Card>
</div>
);
}

View File

@ -1,241 +0,0 @@
import React, { useEffect, useState } from "react";
import { Star, MessageCircle, QrCode, Radio, X, BarChart2, CheckSquare } from "lucide-react";
import { AddQuizPollModal } from "../quiz/AddQuizPollModal";
import { AgendaQrModal } from "../agenda/AgendaQrModal";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext";
import { eventsApi } from "../../../services/api/events";
import type { Event } from "../../../services/api/types";
export function QuickActions() {
const { selectedEventId } = useAppNav();
const [modal, setModal] = useState<string | null>(null);
const [liveActive, setLiveActive] = useState(false);
const [showAddPoll, setShowAddPoll] = useState(false);
const [showAddQuiz, setShowAddQuiz] = useState(false);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
/** `undefined` = fetch in progress for QR modal */
const [qrEvent, setQrEvent] = useState<Event | null | undefined>(undefined);
useEffect(() => {
if (modal !== "qr" || !selectedEventId) {
setQrEvent(undefined);
return;
}
setQrEvent(undefined);
let cancelled = false;
eventsApi.get(selectedEventId).then((res) => {
if (!cancelled) setQrEvent(res.data ?? null);
});
return () => {
cancelled = true;
};
}, [modal, selectedEventId]);
const handleClick = (id: string) => {
if (id === "live") {
if (!liveActive) {
if (!window.confirm("Start broadcasting this event as Live? All participants will be notified.")) return;
}
setLiveActive(v => !v);
return;
}
if (id === "qr" && !selectedEventId) {
window.alert("Select an event from the sidebar first.");
return;
}
if (id === "addpoll") { setShowAddPoll(true); return; }
if (id === "addquiz") { setShowAddQuiz(true); return; }
setModal(id);
};
const actions = [
{ icon: <Star size={isMobile ? 18 : 15} />, label: "Manage Ratings", id: "ratings" },
{ icon: <MessageCircle size={isMobile ? 18 : 15} />, label: "Manage Feedback", id: "feedback" },
{ icon: <QrCode size={isMobile ? 18 : 15} />, label: "Generate QR Code", id: "qr" },
{ icon: <Radio size={isMobile ? 18 : 15} />, label: "Go Live", id: "live" },
{ icon: <BarChart2 size={isMobile ? 18 : 15} />, label: "Add Poll", id: "addpoll" },
{ icon: <CheckSquare size={isMobile ? 18 : 15} />, label: "Add Quiz", id: "addquiz" },
];
return (
<>
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: isMobile ? "14px" : "14px 16px" }}>
{/* ── Label row ── */}
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--muted)", marginBottom: isMobile ? "12px" : "0" }}>
Quick actions
</p>
{/* ── Mobile: 2-column tile grid ── */}
{isMobile ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "8px" }}>
{actions.map((action) => {
const isLive = action.id === "live" && liveActive;
return (
<button
key={action.id}
onClick={() => handleClick(action.id)}
style={{
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "7px", padding: "14px 8px",
borderRadius: "var(--radius-card)",
borderWidth: "1px", borderStyle: "solid",
borderColor: isLive ? "var(--destructive)" : "var(--border)",
background: isLive ? "var(--destructive)" : "var(--secondary)",
color: isLive ? "var(--destructive-foreground)" : "var(--card-foreground)",
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)", cursor: "pointer",
minHeight: "72px",
}}
>
{isLive && <span style={{ width: "6px", height: "6px", borderRadius: "50%", background: "var(--destructive-foreground)", display: "block", position: "absolute" }} />}
<span style={{ color: isLive ? "var(--destructive-foreground)" : "var(--primary)", display: "flex" }}>{action.icon}</span>
<span style={{ textAlign: "center", lineHeight: "1.3" }}>{isLive ? "Stop Live" : action.label}</span>
</button>
);
})}
</div>
) : (
/* ── Desktop: horizontal row ── */
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
<div style={{ width: "1px", height: "18px", background: "var(--border)", flexShrink: 0 }} />
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{actions.map((action) => {
const isLive = action.id === "live" && liveActive;
return (
<button
key={action.id}
onClick={() => handleClick(action.id)}
style={{
display: "flex", alignItems: "center", gap: "6px",
padding: "6px 14px", borderRadius: "var(--radius-button)",
borderWidth: "1px", borderStyle: "solid",
borderColor: isLive ? "var(--destructive)" : "var(--border)",
background: isLive ? "var(--destructive)" : "var(--card)",
color: isLive ? "var(--destructive-foreground)" : "var(--card-foreground)",
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)", cursor: "pointer", transition: "all 0.15s",
}}
onMouseEnter={(e) => {
if (!isLive) {
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}
}}
onMouseLeave={(e) => {
if (!isLive) {
(e.currentTarget as HTMLElement).style.background = "var(--card)";
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}
}}
>
{isLive && <span style={{ width: "7px", height: "7px", borderRadius: "50%", background: "var(--destructive-foreground)", display: "block" }} />}
{action.icon}
{isLive ? "Stop Live" : action.label}
</button>
);
})}
</div>
</div>
)}
</div>
{modal === "qr" && selectedEventId && qrEvent !== undefined && (
<AgendaQrModal
open
onClose={() => setModal(null)}
kind="event"
joinCode={(qrEvent?.joinCode ?? "").trim()}
label={qrEvent?.name ?? "Event"}
/>
)}
{modal === "qr" && selectedEventId && qrEvent === undefined && (
<div
role="status"
aria-live="polite"
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.45)",
zIndex: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "16px",
}}
>
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
padding: "24px 32px",
border: "1px solid var(--border)",
fontFamily: "var(--font-noto-sans)",
fontSize: "var(--text-sm)",
color: "var(--muted)",
}}
>
Loading event…
</div>
</div>
)}
{/* Ratings & feedback modals */}
{modal && modal !== "qr" && (
<div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.35)", zIndex: 50, display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={() => setModal(null)}>
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", width: "440px", maxWidth: "92vw", boxShadow: "var(--elevation-sm)" }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
{modal === "ratings" ? "Event Ratings" : "Attendee Feedback"}
</h3>
<button onClick={() => setModal(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px" }}>
<X size={16} />
</button>
</div>
<div style={{ padding: "20px" }}>
{modal === "ratings" && (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{[{ user: "Dr. Sharma", rating: 5, comment: "Excellent conference, very informative!" }, { user: "Prof. Mehta", rating: 4, comment: "Well organized, good speakers." }, { user: "Dr. Patel", rating: 5, comment: "Best conference of the year!" }].map((r, i) => (
<div key={i} style={{ padding: "12px 14px", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", display: "flex", flexDirection: "column", gap: "4px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{r.user}</span>
<span style={{ color: "var(--chart-2)", fontSize: "var(--text-sm)" }}>{"★".repeat(r.rating)}{"☆".repeat(5 - r.rating)}</span>
</div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{r.comment}</p>
</div>
))}
</div>
)}
{modal === "feedback" && (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{[{ user: "Ananya R.", time: "2h ago", msg: "The morning keynote was excellent." }, { user: "Rajesh K.", time: "4h ago", msg: "Please improve the AV setup for Session 3." }, { user: "Priya M.", time: "1d ago", msg: "Great networking opportunities. Well done!" }].map((f, i) => (
<div key={i} style={{ display: "flex", gap: "10px", padding: "12px 14px", border: "1px solid var(--border)", borderRadius: "var(--radius-button)" }}>
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--accent)", color: "var(--accent-foreground)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "var(--text-xs)", fontFamily: "var(--font-inter)", flexShrink: 0 }}>{f.user[0]}</div>
<div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginBottom: "3px" }}>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{f.user}</span>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{f.time}</span>
</div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{f.msg}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{showAddPoll && <AddQuizPollModal kind="poll" eventId={selectedEventId ?? undefined} onClose={() => setShowAddPoll(false)} />}
{showAddQuiz && <AddQuizPollModal kind="quiz" eventId={selectedEventId ?? undefined} onClose={() => setShowAddQuiz(false)} />}
</>
);
}

View File

@ -0,0 +1,15 @@
import { formsApi } from "../../../services/api";
import type { Form, FormWithSubmissionCount } from "../../../services/api/types";
export const DEFAULT_FEEDBACK_FORM_NAME = "Feedback form";
/** Primary feedback form for this event, if one exists. */
export async function resolveEventFeedbackForm(eventId: string): Promise<FormWithSubmissionCount | null> {
const forms = await formsApi.listForEvent(eventId);
const feedbackForms = forms.filter((f) => f.type === "feedback" || f.type === "survey");
return feedbackForms[0] ?? null;
}
export function feedbackFormDisplayName(form: Form | null): string {
return form?.name?.trim() || DEFAULT_FEEDBACK_FORM_NAME;
}

View File

@ -0,0 +1,40 @@
import { formsApi } from "../../../services/api";
import type { Form, FormField, FormWithSubmissionCount } from "../../../services/api/types";
export const DEFAULT_REGISTRATION_FORM_NAME = "Registration Form";
export const DEFAULT_REGISTRATION_FORM_FIELDS: FormField[] = [
{ label: "First name", type: "text", required: true, mapsTo: "firstName", isFixed: true },
{ label: "Last name", type: "text", required: true, mapsTo: "lastName", isFixed: true },
{ label: "Email", type: "email", required: true, mapsTo: "email", isFixed: true },
];
/** Registration form linked to the event, or null when the default template should be used. */
export async function resolveEventRegistrationForm(
eventId: string,
linkedFormId?: string | null,
): Promise<FormWithSubmissionCount | null> {
const forms = await formsApi.listForEvent(eventId);
const registrationForms = forms.filter((f) => f.type === "registration");
const id = linkedFormId?.trim();
if (id) {
const linked = registrationForms.find((f) => f._id === id);
if (linked) return linked;
try {
const form = await formsApi.get(id);
if (form.type === "registration") {
const count = registrationForms.find((f) => f._id === form._id)?.submissionCount ?? 0;
return { ...form, submissionCount: count };
}
} catch {
// fall through to first registration form or default
}
}
return registrationForms[0] ?? null;
}
export function registrationFormDisplayName(form: Form | null): string {
return form?.name?.trim() || DEFAULT_REGISTRATION_FORM_NAME;
}

View File

@ -9,6 +9,13 @@ import {
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,
@ -166,6 +173,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
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);
@ -287,7 +295,10 @@ export function DesignPage({ subPage }: { subPage?: string }) {
setEditMicrositeEventId(null);
if (activeTab === "Brochure") setShowBrochure(true);
else if (activeTab === "Microsite Templates") setShowMicrosite(true);
else if (activeTab === "Badges") setShowBadge(true);
else if (activeTab === "Badges") {
setDuplicateBadgeBatchId(null);
setShowBadge(true);
}
else if (activeTab === "Emailers") setShowEmailer(true);
};
@ -307,7 +318,10 @@ export function DesignPage({ subPage }: { subPage?: string }) {
setEditMicrositeEventId(null);
if (panel === "brochure") setShowBrochure(false);
else if (panel === "microsite") setShowMicrosite(false);
else if (panel === "badge") setShowBadge(false);
else if (panel === "badge") {
setShowBadge(false);
setDuplicateBadgeBatchId(null);
}
else if (panel === "emailer") setShowEmailer(false);
fetchDesignList();
};
@ -594,6 +608,32 @@ export function DesignPage({ subPage }: { subPage?: string }) {
<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}
@ -790,7 +830,14 @@ export function DesignPage({ subPage }: { subPage?: string }) {
onSuccess={fetchDesignList}
/>
)}
{showBadge && <CreateBadgePanel onClose={() => handleClosePanel("badge")} 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"
@ -1009,23 +1056,6 @@ export function FacultyPage() {
/* ──────────────────────────────────────
All Participants (Global)
────────────────────────────────────── */
function tagChipStyle(tag: string): { bg: string; color: string } {
const defaults: Record<string, { bg: string; color: string }> = {
VIP: { bg: "#fef3c7", color: "#92400e" },
Speaker: { bg: "var(--accent)", color: "var(--accent-foreground)" },
Sponsor: { bg: "#dcfce7", color: "#15803d" },
Press: { bg: "#f3e8ff", color: "#6b21a8" },
Virtual: { bg: "#e0f2fe", color: "#075985" },
Workshop: { bg: "#ffedd5", color: "#c2410c" },
};
const d = defaults[tag];
if (d) return d;
let h = 0;
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h);
const hue = Math.abs(h) % 360;
return { bg: `hsl(${hue}, 42%, 93%)`, color: `hsl(${hue}, 55%, 30%)` };
}
function toAllParticipantRow(p: ParticipantWithEvent) {
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
const statusRaw = p.status;
@ -1045,10 +1075,6 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
return { rowKey: id, id, name, email: p.email ?? "", tags: p.tags ?? [], eventName, status: statusLabel };
}
function parseTagsBulk(s: string): string[] {
return s.split(/[,;\n]+/).map((t) => t.trim()).filter(Boolean);
}
export function AllParticipantsPage() {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
@ -1070,22 +1096,34 @@ export function AllParticipantsPage() {
const [importMsg, setImportMsg] = useState<string | null>(null);
const [listNonce, setListNonce] = useState(0);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectAllMatchingActive, setSelectAllMatchingActive] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [bulkDialog, setBulkDialog] = useState<null | "addTags" | "removeTags" | "assignEvents" | "newList" | "addToList">(null);
const [tagBulkInput, setTagBulkInput] = useState("");
const [newListName, setNewListName] = useState("");
const [newListDesc, setNewListDesc] = useState("");
const [eventsPick, setEventsPick] = useState<Event[]>([]);
const [eventPickIds, setEventPickIds] = useState<Set<string>>(() => new Set());
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [addToListId, setAddToListId] = useState("");
const headerSelectRef = useRef<HTMLInputElement>(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); };
@ -1093,12 +1131,6 @@ export function AllParticipantsPage() {
useEffect(() => { setPage(1); }, [search, activeTag]);
useEffect(() => { setSelectAllMatchingActive(false); }, [search, activeTag]);
useEffect(() => {
if (selectedIds.length === 0) setSelectAllMatchingActive(false);
}, [selectedIds.length]);
useEffect(() => {
if (!listOrgId) {
setTagOptions([]);
@ -1109,15 +1141,6 @@ export function AllParticipantsPage() {
});
}, [listOrgId, listNonce]);
useEffect(() => {
const el = headerSelectRef.current;
if (!el || list.length === 0) return;
const vis = list.map((p) => p.id);
const n = vis.filter((id) => selectedIds.includes(id)).length;
el.indeterminate = n > 0 && n < vis.length;
el.checked = n === vis.length && vis.length > 0;
}, [list, selectedIds]);
useEffect(() => {
if (!listOrgId) {
setList([]);
@ -1158,29 +1181,10 @@ export function AllParticipantsPage() {
setExporting(false);
};
const toggleRow = (id: string) => {
setSelectAllMatchingActive(false);
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleAllVisible = () => {
setSelectAllMatchingActive(false);
const vis = list.map((p) => p.id);
setSelectedIds((prev) => {
const set = new Set(prev);
const allOn = vis.length > 0 && vis.every((id) => set.has(id));
if (allOn) vis.forEach((id) => set.delete(id));
else vis.forEach((id) => set.add(id));
return [...set];
});
};
const selectAllMatchingSearch = async () => {
setBulkBusy(true);
if (!listOrgId) {
setImportMsg(isSuperAdmin ? "Organisation required for this action." : "Your profile has no organisation.");
setSelectAllMatchingActive(false);
setBulkBusy(false);
return;
}
const r = await participantsApi.matchingIds({
@ -1188,7 +1192,6 @@ export function AllParticipantsPage() {
q: search || undefined,
tag: activeTag ?? undefined,
});
setBulkBusy(false);
if (r.error) {
setImportMsg(r.error);
setSelectAllMatchingActive(false);
@ -1200,137 +1203,6 @@ export function AllParticipantsPage() {
setSelectAllMatchingActive(ids.length > 0);
};
const clearSelection = () => {
setSelectAllMatchingActive(false);
setSelectedIds([]);
};
const openBulkDialog = async (kind: NonNullable<typeof bulkDialog>) => {
setTagBulkInput("");
setNewListName("");
setNewListDesc("");
setAddToListId("");
setEventPickIds(new Set());
if (kind === "assignEvents") {
if (!listOrgId) {
setImportMsg(isSuperAdmin ? "Organisation required to load events." : "Your profile has no organisation.");
return;
}
const er = await eventsApi.list({ limit: 400, organization: listOrgId });
if (er.data?.events) setEventsPick(er.data.events);
}
if (kind === "addToList") {
if (!listOrgId) {
setImportMsg(isSuperAdmin ? "Organisation required to load lists." : "Your profile has no organisation.");
return;
}
const lr = await participantListsApi.list({ organization: listOrgId });
const fixed = (lr.data?.lists ?? []).filter((L) => L.type === "fixed");
setFixedLists(fixed);
if (fixed[0]) setAddToListId(fixed[0]._id);
}
setBulkDialog(kind);
};
const toggleEventPick = (eid: string) => {
setEventPickIds((prev) => {
const next = new Set(prev);
if (next.has(eid)) next.delete(eid);
else next.add(eid);
return next;
});
};
const runBulkTags = async (action: "add_tags" | "remove_tags") => {
const tags = parseTagsBulk(tagBulkInput);
if (!tags.length) return;
setBulkBusy(true);
const res = await participantsApi.bulk({
action,
participantIds: selectedIds,
tags,
});
setBulkBusy(false);
if (res.error) setImportMsg(res.error);
else {
setImportMsg(`${action === "add_tags" ? "Added" : "Removed"} tags for ${res.data?.updated ?? 0} participant(s).`);
setBulkDialog(null);
setSelectedIds([]);
setListNonce((n) => n + 1);
}
};
const runBulkAssignEvents = async () => {
const eventIds = [...eventPickIds];
if (!eventIds.length) return;
setBulkBusy(true);
const res = await participantsApi.bulk({
action: "assign_events",
participantIds: selectedIds,
eventIds,
});
setBulkBusy(false);
if (res.error) setImportMsg(res.error);
else {
setImportMsg(`Assigned ${selectedIds.length} people to event(s). ${res.data?.membershipsAdded ?? 0} new membership(s).`);
setBulkDialog(null);
setSelectedIds([]);
setListNonce((n) => n + 1);
}
};
const runBulkNewList = async () => {
const name = newListName.trim();
if (!name) return;
setBulkBusy(true);
const res = await participantListsApi.create({
name,
description: newListDesc.trim(),
type: "fixed",
visibility: "private",
autoUpdate: false,
participantIds: selectedIds,
});
setBulkBusy(false);
if (res.error) setImportMsg(res.error);
else {
setImportMsg(`Custom list “${name}” created with ${selectedIds.length} member(s).`);
setBulkDialog(null);
setSelectedIds([]);
setListNonce((n) => n + 1);
}
};
const runBulkAddToList = async () => {
if (!addToListId) return;
setBulkBusy(true);
const res = await participantsApi.bulk({
action: "add_to_fixed_list",
participantIds: selectedIds,
listId: addToListId,
});
setBulkBusy(false);
if (res.error) setImportMsg(res.error);
else {
setImportMsg(`Added ${res.data?.updated ?? 0} people to the list.`);
setBulkDialog(null);
setSelectedIds([]);
setListNonce((n) => n + 1);
}
};
const runBulkDelete = async () => {
setBulkBusy(true);
const res = await participantsApi.bulk({ action: "delete", participantIds: selectedIds });
setBulkBusy(false);
if (res.error) setImportMsg(res.error);
else {
setImportMsg(`Deleted ${res.data?.deleted ?? 0} participant(s).`);
setSelectedIds([]);
setListNonce((n) => n + 1);
}
};
const confirmDeleteOne = async () => {
if (!deleteTarget) return;
const { id, name } = deleteTarget;
@ -1380,33 +1252,16 @@ export function AllParticipantsPage() {
{/* Filter row */}
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
{!loading && total > 0 && (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
flexShrink: 0,
cursor: bulkBusy ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={selectAllMatchingActive}
disabled={bulkBusy}
onChange={(e) => {
if (e.target.checked) void selectAllMatchingSearch();
else clearSelection();
}}
style={{ width: "16px", height: "16px", cursor: bulkBusy ? "wait" : "pointer", accentColor: "var(--primary)" }}
/>
<span>Select all matching filters ({total})</span>
</label>
)}
<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 }} />
@ -1415,43 +1270,26 @@ export function AllParticipantsPage() {
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 (from your data) */}
<div style={{ display: "flex", gap: "5px", flexWrap: "wrap", alignItems: "center" }}>
{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>
) : (
tagOptions.map((tag) => {
const tc = tagChipStyle(tag);
const isActive = activeTag === tag;
return (
<button key={tag} type="button" onClick={() => setActiveTag(isActive ? null : tag)}
style={{ padding: "3px 10px", borderRadius: "999px", border: `1px solid ${isActive ? tc.color : "var(--border)"}`, background: isActive ? tc.bg : "transparent", color: isActive ? tc.color : "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: isActive ? "var(--font-weight-medium)" : "var(--font-weight-normal)", cursor: "pointer", transition: "all 0.12s" }}>
{tag}
</button>
);
})
)}
{activeTag && (
<button type="button" onClick={() => setActiveTag(null)}
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "3px 10px", borderRadius: "999px", border: "1px solid var(--border)", background: "transparent", color: "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
<X size={10} /> Clear
</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>
{selectedIds.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "8px", padding: "12px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)" }}>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{selectedIds.length} selected</span>
<button type="button" disabled={bulkBusy} onClick={() => openBulkDialog("addTags")} style={{ padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: bulkBusy ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Add tags</button>
<button type="button" disabled={bulkBusy} onClick={() => openBulkDialog("removeTags")} style={{ padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: bulkBusy ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Remove tags</button>
<button type="button" disabled={bulkBusy} onClick={() => openBulkDialog("assignEvents")} style={{ padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: bulkBusy ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Assign to event(s)</button>
<button type="button" disabled={bulkBusy} onClick={() => openBulkDialog("newList")} style={{ padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: bulkBusy ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>New custom list</button>
<button type="button" disabled={bulkBusy} onClick={() => openBulkDialog("addToList")} style={{ padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: bulkBusy ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Add to list</button>
<button type="button" disabled={bulkBusy} onClick={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void runBulkDelete(); }} style={{ padding: "6px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--destructive)", background: "transparent", color: "var(--destructive)", cursor: bulkBusy ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete selected</button>
<button type="button" onClick={clearSelection} style={{ marginLeft: "auto", padding: "6px 12px", borderRadius: "var(--radius-button)", border: "none", background: "transparent", color: "var(--muted)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Clear selection</button>
</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}>
@ -1587,74 +1425,28 @@ export function AllParticipantsPage() {
variant="danger"
/>
)}
{(bulkDialog === "addTags" || bulkDialog === "removeTags") && (
<div role="dialog" aria-modal="true" style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 205, display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => !bulkBusy && setBulkDialog(null)}>
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px", width: "min(400px, 92vw)", boxShadow: "var(--shadow-xl)" }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Apply to {selectedIds.length} selected (comma-separated).</p>
<input value={tagBulkInput} onChange={(e) => setTagBulkInput(e.target.value)} placeholder="e.g. VIP, Sponsor" style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", boxSizing: "border-box", marginBottom: "14px" }} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button type="button" disabled={bulkBusy} onClick={() => setBulkDialog(null)} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Cancel</button>
<button type="button" disabled={bulkBusy} onClick={() => void runBulkTags(bulkDialog === "addTags" ? "add_tags" : "remove_tags")} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Apply</button>
</div>
</div>
</div>
)}
{bulkDialog === "assignEvents" && (
<div role="dialog" aria-modal="true" style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 205, display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => !bulkBusy && setBulkDialog(null)}>
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px", width: "min(440px, 92vw)", maxHeight: "80vh", overflow: "auto", boxShadow: "var(--shadow-xl)" }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>Assign to event(s)</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{selectedIds.length} people · adds pending membership where missing.</p>
<div style={{ maxHeight: "240px", overflowY: "auto", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", padding: "8px" }}>
{eventsPick.map((ev) => (
<label key={ev._id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 4px", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<input type="checkbox" checked={eventPickIds.has(ev._id)} onChange={() => toggleEventPick(ev._id)} />
{ev.name}
</label>
))}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "14px" }}>
<button type="button" disabled={bulkBusy} onClick={() => setBulkDialog(null)} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Cancel</button>
<button type="button" disabled={bulkBusy || eventPickIds.size === 0} onClick={() => void runBulkAssignEvents()} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Assign</button>
</div>
</div>
</div>
)}
{bulkDialog === "newList" && (
<div role="dialog" aria-modal="true" style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 205, display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => !bulkBusy && setBulkDialog(null)}>
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px", width: "min(400px, 92vw)", boxShadow: "var(--shadow-xl)" }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>New custom list</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Fixed list with {selectedIds.length} selected people.</p>
<input value={newListName} onChange={(e) => setNewListName(e.target.value)} placeholder="List name" style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", marginBottom: "8px", boxSizing: "border-box", fontSize: "var(--text-sm)" }} />
<textarea value={newListDesc} onChange={(e) => setNewListDesc(e.target.value)} placeholder="Description (optional)" rows={3} style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", marginBottom: "14px", boxSizing: "border-box", fontSize: "var(--text-sm)", resize: "vertical" }} />
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button type="button" disabled={bulkBusy} onClick={() => setBulkDialog(null)} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Cancel</button>
<button type="button" disabled={bulkBusy || !newListName.trim()} onClick={() => void runBulkNewList()} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Create</button>
</div>
</div>
</div>
)}
{bulkDialog === "addToList" && (
<div role="dialog" aria-modal="true" style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 205, display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => !bulkBusy && setBulkDialog(null)}>
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px", width: "min(400px, 92vw)", boxShadow: "var(--shadow-xl)" }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>Add to existing list</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Append {selectedIds.length} people to a fixed list.</p>
{fixedLists.length === 0 ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>No fixed lists yet. Create one first.</p>
) : (
<select value={addToListId} onChange={(e) => setAddToListId(e.target.value)} style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", marginBottom: "14px", fontSize: "var(--text-sm)" }}>
{fixedLists.map((L) => (
<option key={L._id} value={L._id}>{L.name}</option>
))}
</select>
)}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button type="button" disabled={bulkBusy} onClick={() => setBulkDialog(null)} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Cancel</button>
<button type="button" disabled={bulkBusy || fixedLists.length === 0 || !addToListId} onClick={() => void runBulkAddToList()} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Add</button>
</div>
</div>
</div>
)}
<ParticipantBulkDialogs
bulkDialog={bulk.bulkDialog}
bulkBusy={bulk.bulkBusy}
selectedCount={selectedIds.length}
tagBulkInput={bulk.tagBulkInput}
onTagBulkInputChange={bulk.setTagBulkInput}
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) => void bulk.runBulkTags(action)}
onAssignEvents={() => void bulk.runBulkAssignEvents()}
onCreateList={() => void bulk.runBulkNewList()}
onAddToList={() => void bulk.runBulkAddToList()}
/>
<Footer />
{showImport && (
<ImportParticipantsPanel
@ -2046,6 +1838,14 @@ function formEventName(f: FormWithSubmissionCount): string {
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;
@ -2083,11 +1883,19 @@ export function FormsPage({ subPage }: { subPage?: string }) {
const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null);
const [copiedFormId, setCopiedFormId] = useState<string | null>(null);
const publicFormPageUrl = (id: string) => `${window.location.origin.replace(/\/$/, "")}/form/${id}`;
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 = (id: string) => {
const url = publicFormPageUrl(id);
const copyFormPublicLink = (f: FormWithSubmissionCount) => {
const url = publicFormPageUrl(f);
void navigator.clipboard.writeText(url).then(() => {
setCopiedFormId(id);
setFormPageMsg(FORM_LINK_COPIED_MSG);
@ -2266,7 +2074,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
<button
type="button"
title={copiedFormId === fid ? "Copied to clipboard" : "Copy public form link"}
onClick={() => copyFormPublicLink(fid)}
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)"; } }}

View File

@ -0,0 +1,528 @@
/**
* Live badge preview canvas — draggable elements, resize handles, sample data.
*/
import React, { useCallback, useEffect, useRef } from "react";
import QRCode from "qrcode";
import { ImageIcon } from "lucide-react";
import type { BadgeLayoutElement } from "./badgeLayout";
import {
applyElementSize,
clampElementPosition,
elementSizePct,
fontCss,
getBadgeAspectRatio,
imageUrlForElement,
isResizableElement,
isTextElement,
previewTextForElement,
samplePreviewData,
previewAvatarColor,
previewInitialsFromName,
textAlignFor,
usesFullWidthCenter,
usesShrinkToFit,
type BadgePreviewSample,
} from "./badgeLayout";
type DragState = {
id: string;
startX: number;
startY: number;
origX: number;
origY: number;
};
type ResizeState = {
id: string;
startX: number;
startY: number;
origW: number;
};
export function BadgeDesignerPreview({
aspectRatio,
backgroundMode,
backgroundColor,
backgroundImageUrl,
layoutElements,
customImages,
badgeTypeLabel,
badgeAspectRatio,
selectedId,
onSelect,
onLayoutChange,
}: {
aspectRatio: number;
badgeAspectRatio: number;
backgroundMode: "image" | "color";
backgroundColor: string;
backgroundImageUrl: string;
layoutElements: BadgeLayoutElement[];
customImages: { id: string; imageUrl: string }[];
badgeTypeLabel: string;
selectedId: string | null;
onSelect: (id: string | null) => void;
onLayoutChange: (next: BadgeLayoutElement[]) => void;
}) {
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef(layoutElements);
const onLayoutChangeRef = useRef(onLayoutChange);
const dragRef = useRef<DragState | null>(null);
const resizeRef = useRef<ResizeState | null>(null);
const qrUrlRef = useRef("");
const [, setTick] = React.useState(0);
layoutRef.current = layoutElements;
onLayoutChangeRef.current = onLayoutChange;
const sample = samplePreviewData(badgeTypeLabel);
useEffect(() => {
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
.then((url) => {
qrUrlRef.current = url;
setTick((t) => t + 1);
})
.catch(() => {
qrUrlRef.current = "";
});
}, []);
const patchElement = useCallback((id: string, patch: Partial<BadgeLayoutElement>) => {
const next = layoutRef.current.map((el) => (el.id === id ? { ...el, ...patch } : el));
layoutRef.current = next;
onLayoutChangeRef.current(next);
}, []);
useEffect(() => {
const onMove = (e: PointerEvent) => {
const box = canvasRef.current?.getBoundingClientRect();
if (!box?.width || !box.height) return;
const drag = dragRef.current;
if (drag) {
const el = layoutRef.current.find((x) => x.id === drag.id);
if (!el) return;
e.preventDefault();
const dx = ((e.clientX - drag.startX) / box.width) * 100;
const dy = ((e.clientY - drag.startY) / box.height) * 100;
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy);
patchElement(drag.id, pos);
return;
}
const resize = resizeRef.current;
if (resize) {
const el = layoutRef.current.find((x) => x.id === resize.id);
if (!el) return;
e.preventDefault();
const dx = ((e.clientX - resize.startX) / box.width) * 100;
const dy = ((e.clientY - resize.startY) / box.height) * 100;
const delta = (dx + dy) / 2;
patchElement(resize.id, applyElementSize(el, resize.origW + delta, badgeAspectRatio));
}
};
const end = () => {
dragRef.current = null;
resizeRef.current = null;
};
window.addEventListener("pointermove", onMove, { passive: false });
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", end);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end);
};
}, [patchElement]);
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
if (resizeRef.current) return;
e.preventDefault();
e.stopPropagation();
onSelect(el.id);
dragRef.current = {
id: el.id,
startX: e.clientX,
startY: e.clientY,
origX: el.x,
origY: el.y,
};
};
const startResize = (e: React.PointerEvent, el: BadgeLayoutElement) => {
e.preventDefault();
e.stopPropagation();
onSelect(el.id);
dragRef.current = null;
resizeRef.current = {
id: el.id,
startX: e.clientX,
startY: e.clientY,
origW: elementSizePct(el),
};
};
const bgStyle: React.CSSProperties =
backgroundMode === "image" && backgroundImageUrl.trim()
? {
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: { backgroundColor };
return (
<div style={{ padding: "8px 12px 16px 20px" }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 10px" }}>
Live preview — drag to move; drag corner handle to resize
</p>
<div
ref={canvasRef}
role="presentation"
onClick={() => onSelect(null)}
style={{
width: "100%",
maxWidth: 420,
aspectRatio: String(aspectRatio),
position: "relative",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
overflow: "hidden",
containerType: "size",
touchAction: "none",
...bgStyle,
cursor: "default",
userSelect: "none",
}}
>
{layoutElements.map((el) => (
<PreviewElement
key={el.id}
el={el}
sample={sample}
customImages={customImages}
selected={selectedId === el.id}
qrUrl={qrUrlRef.current}
onDragStart={startDrag}
onResizeStart={startResize}
/>
))}
</div>
</div>
);
}
function PreviewElement({
el,
sample,
customImages,
selected,
qrUrl,
onDragStart,
onResizeStart,
}: {
el: BadgeLayoutElement;
sample: BadgePreviewSample;
customImages: { id: string; imageUrl: string }[];
selected: boolean;
qrUrl: string;
onDragStart: (e: React.PointerEvent, el: BadgeLayoutElement) => void;
onResizeStart: (e: React.PointerEvent, el: BadgeLayoutElement) => void;
}) {
const border = selected ? "2px solid var(--primary)" : "1px dashed transparent";
const resizable = isResizableElement(el);
const size = elementSizePct(el);
const wrap = (child: React.ReactNode, extra?: React.CSSProperties) => (
<div
onPointerDown={(e) => onDragStart(e, el)}
onClick={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: `${el.x}%`,
top: `${el.y}%`,
width: `${size}%`,
aspectRatio: "1",
border,
borderRadius: el.field === "photo" ? "50%" : 4,
cursor: "grab",
boxSizing: "border-box",
touchAction: "none",
...extra,
}}
>
{child}
{selected && resizable && (
<div
role="presentation"
onPointerDown={(e) => onResizeStart(e, el)}
style={{
position: "absolute",
right: -5,
bottom: -5,
width: 14,
height: 14,
borderRadius: 3,
background: "var(--primary)",
border: "2px solid var(--card)",
cursor: "nwse-resize",
touchAction: "none",
zIndex: 2,
}}
/>
)}
</div>
);
if (el.kind === "custom_image") {
const url = imageUrlForElement(el, customImages);
const wPct = el.w ?? 25;
const hPct = el.h ?? 25;
return (
<div
onPointerDown={(e) => onDragStart(e, el)}
onClick={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: `${el.x}%`,
top: `${el.y}%`,
width: `${wPct}%`,
height: `${hPct}%`,
border,
borderRadius: 4,
cursor: "grab",
boxSizing: "border-box",
touchAction: "none",
overflow: "hidden",
background: "#fff",
}}
>
{url ? (
<img
src={url}
alt=""
draggable={false}
style={{ width: "100%", height: "100%", objectFit: "contain", pointerEvents: "none" }}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ImageIcon size={18} style={{ color: "var(--muted)" }} />
</div>
)}
{selected && resizable && (
<div
role="presentation"
onPointerDown={(e) => onResizeStart(e, el)}
style={{
position: "absolute",
right: -5,
bottom: -5,
width: 14,
height: 14,
borderRadius: 3,
background: "var(--primary)",
border: "2px solid var(--card)",
cursor: "nwse-resize",
touchAction: "none",
zIndex: 2,
}}
/>
)}
</div>
);
}
if (el.field === "photo") {
const initials = previewInitialsFromName(sample.name);
const bg = previewAvatarColor(sample.name);
return wrap(
<div
style={{
width: "100%",
height: "100%",
borderRadius: "50%",
background: bg,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontFamily: "var(--font-inter)",
fontWeight: 600,
fontSize: "clamp(10px, 42%, 22px)",
pointerEvents: "none",
}}
>
{initials}
</div>,
);
}
if (el.field === "qr") {
return wrap(
<div
style={{
width: "100%",
height: "100%",
background: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
borderRadius: 4,
}}
>
{qrUrl ? (
<img
src={qrUrl}
alt=""
draggable={false}
style={{ width: "100%", height: "100%", objectFit: "contain", pointerEvents: "none" }}
/>
) : (
<span style={{ fontSize: 8, color: "var(--muted)" }}>QR</span>
)}
</div>,
{ borderRadius: 4, overflow: "hidden" },
);
}
if (!isTextElement(el)) return null;
return (
<TextPreviewBlock
el={el}
sample={sample}
selected={selected}
border={border}
onDragStart={onDragStart}
/>
);
}
function TextPreviewBlock({
el,
sample,
selected,
border,
onDragStart,
}: {
el: BadgeLayoutElement;
sample: BadgePreviewSample;
selected: boolean;
border: string;
onDragStart: (e: React.PointerEvent, el: BadgeLayoutElement) => void;
}) {
const textRef = useRef<HTMLDivElement>(null);
const [displayPct, setDisplayPct] = React.useState(el.fontSizePct ?? 5);
const text = previewTextForElement(el, sample);
const fullWidth = usesFullWidthCenter(el);
const align = textAlignFor(el);
const basePct = el.fontSizePct ?? 5;
const widthPct = fullWidth ? 100 : (el.w ?? 72);
useEffect(() => {
setDisplayPct(basePct);
}, [basePct, text, fullWidth, widthPct, align, el.shrinkToFit]);
useEffect(() => {
const node = textRef.current;
const box = node?.parentElement;
if (!node || !box) return;
if (!usesShrinkToFit(el)) {
setDisplayPct(basePct);
return;
}
let pct = basePct;
const apply = (p: number) => {
node.style.fontSize = `calc(${p} * 1cqh)`;
};
apply(pct);
const fits = () => node.scrollWidth <= box.clientWidth + 1;
if (fits()) {
setDisplayPct(pct);
return;
}
for (let i = 0; i < 32 && pct > 1.5; i += 1) {
pct -= 0.25;
apply(pct);
if (fits()) break;
}
setDisplayPct(pct);
}, [el, text, basePct, widthPct, fullWidth, align]);
return (
<div
onPointerDown={(e) => onDragStart(e, el)}
onClick={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: fullWidth ? 0 : `${el.x}%`,
top: `${el.y}%`,
width: `${widthPct}%`,
border,
borderRadius: 2,
padding: "2px 4px",
cursor: "grab",
boxSizing: "border-box",
textAlign: align,
touchAction: "none",
}}
>
<div
ref={textRef}
style={{
fontFamily: fontCss(el.fontFamily),
fontSize: `calc(${displayPct} * 1cqh)`,
fontWeight: el.fontWeight === "bold" ? 700 : 400,
color: el.color ?? "#0f172a",
lineHeight: 1.15,
whiteSpace: usesShrinkToFit(el) ? "nowrap" : "normal",
wordBreak: usesShrinkToFit(el) ? "normal" : "break-word",
overflow: usesShrinkToFit(el) ? "hidden" : "visible",
display: "block",
width: "100%",
pointerEvents: "none",
}}
>
{el.field === "badge_type" ? (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 4,
background: "rgba(15,23,42,0.12)",
}}
>
{text}
</span>
) : (
text
)}
</div>
</div>
);
}
export function useBadgeAspectRatio(
sizePreset: "a6" | "a7" | "4x3in" | "4x6in" | "custom",
orientation: "portrait" | "landscape",
customW: string,
customH: string,
): number {
return getBadgeAspectRatio(sizePreset, orientation, customW, customH);
}

View File

@ -0,0 +1,349 @@
/**
* Floating field styling panel — appears beside the badge preview.
*/
import React, { useRef, useState } from "react";
import { Trash2, Upload } from "lucide-react";
import { Label, SelectWrap, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
import type { BadgeLayoutElement } from "./badgeLayout";
import {
BADGE_FONT_FAMILIES,
RESIZABLE_SIZE_MAX,
RESIZABLE_SIZE_MIN,
applyElementSize,
heightPctForImageWidth,
imageUrlForElement,
isResizableElement,
readFileImageAspectRatio,
isTextElement,
usesFullWidthCenter,
type BadgeTextAlign,
} from "./badgeLayout";
import { uploadMediaFile } from "../../../services/api";
const FONT_SIZE_OPTIONS = [3, 3.5, 4, 4.5, 5, 6, 7, 8, 9, 10];
const panelStyle: React.CSSProperties = {
position: "absolute",
left: "calc(100% + 12px)",
top: 36,
width: 240,
zIndex: 10,
padding: 14,
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
boxShadow: "0 8px 28px rgba(0,0,0,0.15)",
display: "flex",
flexDirection: "column",
gap: 10,
maxHeight: "calc(100vh - 120px)",
overflowY: "auto",
};
export function BadgeFieldInspector({
element,
customTextValue,
customImages,
badgeAspectRatio,
onChange,
onRemoveCustom,
onCustomImageUploaded,
}: {
element: BadgeLayoutElement | null;
customTextValue?: string;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
}) {
if (!element) return null;
if (isTextElement(element)) {
return (
<TextInspector
element={element}
customTextValue={customTextValue}
onChange={onChange}
onRemoveCustom={onRemoveCustom}
/>
);
}
if (isResizableElement(element)) {
return (
<ResizableInspector
element={element}
customImages={customImages}
badgeAspectRatio={badgeAspectRatio}
onChange={onChange}
onRemoveCustom={onRemoveCustom}
onCustomImageUploaded={onCustomImageUploaded}
/>
);
}
return null;
}
function TextInspector({
element,
customTextValue,
onChange,
onRemoveCustom,
}: {
element: BadgeLayoutElement;
customTextValue?: string;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
}) {
const fullWidth = usesFullWidthCenter(element);
const setFullWidth = (on: boolean) => {
if (on) {
onChange({
fullWidthCenter: true,
x: 0,
w: 100,
textAlign: element.textAlign ?? "center",
});
} else {
onChange({
fullWidthCenter: false,
x: element.x > 0 ? element.x : 6,
w: 72,
textAlign: element.textAlign === "center" ? "left" : element.textAlign ?? "left",
});
}
};
return (
<div role="dialog" aria-label="Selected field styling" onClick={(e) => e.stopPropagation()} style={panelStyle}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
Selected field styling
</p>
{element.kind === "custom_text" && (
<>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Text</span>
<input
style={{ ...IS, marginTop: 4 }}
value={element.text ?? customTextValue ?? ""}
onChange={(e) => onChange({ text: e.target.value })}
onFocus={focusIn}
onBlur={focusOut}
/>
</div>
{onRemoveCustom && (
<button type="button" onClick={onRemoveCustom} style={removeBtnStyle}>
<Trash2 size={12} /> Remove fixed text
</button>
)}
</>
)}
<label style={checkboxLabelStyle}>
<input type="checkbox" checked={fullWidth} onChange={(e) => setFullWidth(e.target.checked)} style={{ marginTop: 3 }} />
<span>Full width &amp; center text</span>
</label>
<label style={checkboxLabelStyle}>
<input
type="checkbox"
checked={element.shrinkToFit !== false}
onChange={(e) => onChange({ shrinkToFit: e.target.checked })}
style={{ marginTop: 3 }}
/>
<span>Shrink font if text overflows badge</span>
</label>
<div>
<Label text="Align" />
<RadioGroup
name="textAlign"
value={element.textAlign ?? (fullWidth ? "center" : "left")}
onChange={(v) => onChange({ textAlign: v as BadgeTextAlign })}
options={[
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
]}
/>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font colour</span>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 4 }}>
<input type="color" value={element.color ?? "#0f172a"} onChange={(e) => onChange({ color: e.target.value })} style={{ width: 40, height: 32, border: "none", cursor: "pointer" }} />
<input style={{ ...IS, flex: 1 }} value={element.color ?? "#0f172a"} onChange={(e) => onChange({ color: e.target.value })} onFocus={focusIn} onBlur={focusOut} />
</div>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font family</span>
<SelectWrap>
<select style={{ ...SS, marginTop: 4 }} value={element.fontFamily ?? "Inter"} onChange={(e) => onChange({ fontFamily: e.target.value })} onFocus={focusIn} onBlur={focusOut}>
{BADGE_FONT_FAMILIES.map((ff) => (
<option key={ff.id} value={ff.id}>{ff.label}</option>
))}
</select>
</SelectWrap>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Font size</span>
<SelectWrap>
<select style={{ ...SS, marginTop: 4 }} value={String(element.fontSizePct ?? 5)} onChange={(e) => onChange({ fontSizePct: parseFloat(e.target.value) })} onFocus={focusIn} onBlur={focusOut}>
{FONT_SIZE_OPTIONS.map((s) => (
<option key={s} value={String(s)}>{s}%</option>
))}
</select>
</SelectWrap>
</div>
<div>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>Weight</span>
<RadioGroup
name="fontWeight"
value={element.fontWeight ?? "normal"}
onChange={(v) => onChange({ fontWeight: v as "normal" | "bold" })}
options={[
{ label: "Normal", value: "normal" },
{ label: "Bold", value: "bold" },
]}
/>
</div>
<HelperText text="Drag the field on the preview to move it." />
</div>
);
}
function ResizableInspector({
element,
customImages,
badgeAspectRatio,
onChange,
onRemoveCustom,
onCustomImageUploaded,
}: {
element: BadgeLayoutElement;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const size = element.w ?? 28;
const label =
element.field === "qr" ? "QR code" : element.field === "photo" ? "Photo" : "Fixed image";
const onSizeChange = (w: number) => {
onChange(applyElementSize(element, w, badgeAspectRatio));
};
const onFile = async (file: File | undefined) => {
if (!file?.type.startsWith("image/") || element.kind !== "custom_image") return;
setUploading(true);
try {
const imageAspectRatio = await readFileImageAspectRatio(file);
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
if (r.url) {
const w = element.w ?? 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
onChange({ imageUrl: r.url, imageAspectRatio, h });
onCustomImageUploaded?.(element.id, r.url, imageAspectRatio);
}
} finally {
setUploading(false);
}
};
const imgUrl = element.kind === "custom_image" ? imageUrlForElement(element, customImages) : "";
return (
<div role="dialog" aria-label="Selected element styling" onClick={(e) => e.stopPropagation()} style={panelStyle}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
{label}
</p>
{element.kind === "custom_image" && (
<>
{imgUrl ? (
<img src={imgUrl} alt="" style={{ maxWidth: "100%", maxHeight: 80, objectFit: "contain", borderRadius: 4, border: "1px solid var(--border)" }} />
) : null}
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} disabled={uploading} onChange={(e) => { void onFile(e.target.files?.[0]); e.target.value = ""; }} />
<button type="button" disabled={uploading} onClick={() => fileRef.current?.click()} style={uploadBtnStyle}>
<Upload size={14} />
{uploading ? "Uploading…" : imgUrl ? "Replace image" : "Upload image"}
</button>
{onRemoveCustom && (
<button type="button" onClick={onRemoveCustom} style={removeBtnStyle}>
<Trash2 size={12} /> Remove fixed image
</button>
)}
</>
)}
<div>
<Label text="Size" />
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6 }}>
<input
type="range"
min={RESIZABLE_SIZE_MIN}
max={RESIZABLE_SIZE_MAX}
step={1}
value={size}
onChange={(e) => onSizeChange(parseFloat(e.target.value))}
style={{ flex: 1 }}
/>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", minWidth: 36 }}>
{Math.round(size)}%
</span>
</div>
</div>
<HelperText text="Drag to move. Use the blue corner handle on the preview or the slider above to resize." />
</div>
);
}
const checkboxLabelStyle: React.CSSProperties = {
display: "flex",
alignItems: "flex-start",
gap: 8,
fontSize: "var(--text-sm)",
cursor: "pointer",
};
const removeBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
border: "1px solid var(--destructive)",
color: "var(--destructive)",
background: "transparent",
borderRadius: "var(--radius-button)",
cursor: "pointer",
fontSize: "var(--text-xs)",
alignSelf: "flex-start",
};
const uploadBtnStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 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)",
};

View File

@ -1,8 +1,8 @@
/**
* CreateBadgePanel — Request a queued badge export (PDF/PNG/JPEG zip on S3).
*/
import React, { useState, useEffect } from "react";
import { Check, QrCode, User, Building2, Hash } from "lucide-react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Check, QrCode, User, Building2, Hash, Plus, Upload } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import {
PanelShell, AccordionSection, useAccordion,
@ -17,6 +17,23 @@ import {
uploadMediaFile,
} from "../../../services/api";
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
import { BadgeFieldInspector } from "./BadgeFieldInspector";
import type { BadgeLayoutElement } from "./badgeLayout";
import {
MAX_CUSTOM_IMAGE_FIELDS,
MAX_CUSTOM_TEXT_FIELDS,
applyElementSize,
buildCustomImageElement,
clampElementPosition,
defaultLayoutForFields,
heightPctForImageWidth,
newCustomImageId,
newCustomTextId,
readFileImageAspectRatio,
syncLayoutWithFields,
} from "./badgeLayout";
import { formStateFromBadgeBatch } from "./badgeBatchFormState";
const BADGE_SIZES: { id: "a6" | "a7" | "4x3in" | "4x6in" | "custom"; label: string }[] = [
{ id: "a6", label: "A6 (105 × 148 mm)" },
@ -35,12 +52,116 @@ const BADGE_FIELDS = [
{ id: "photo", icon: <User size={13} />, label: "Photo (faculty)" },
];
function FixedImageAddButton({
busy,
disabled,
onFile,
}: {
busy: boolean;
disabled?: boolean;
onFile: (file: File) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<input
ref={ref}
type="file"
accept="image/*"
style={{ display: "none" }}
disabled={busy || disabled}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onFile(f);
e.target.value = "";
}}
/>
<button
type="button"
disabled={busy || disabled}
onClick={() => ref.current?.click()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: busy || disabled ? "not-allowed" : "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
opacity: busy || disabled ? 0.6 : 1,
}}
>
<Plus size={12} /> {busy ? "Uploading…" : "Add fixed image"}
</button>
</>
);
}
function BackgroundUploadButton({
busy,
disabled,
onFile,
}: {
busy: boolean;
disabled?: boolean;
onFile: (file: File | undefined) => void;
}) {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<input
ref={ref}
type="file"
accept="image/*"
style={{ display: "none" }}
disabled={busy || disabled}
onChange={(e) => {
onFile(e.target.files?.[0]);
e.target.value = "";
}}
/>
<button
type="button"
disabled={busy || disabled}
onClick={() => ref.current?.click()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
marginTop: 8,
padding: "8px 14px",
borderRadius: "var(--radius-button)",
background: busy ? "var(--secondary)" : "var(--primary)",
color: busy ? "var(--muted)" : "var(--primary-foreground)",
border: "none",
cursor: busy || disabled ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
}}
>
<Upload size={14} />
{busy ? "Uploading…" : "Choose file"}
</button>
</>
);
}
export function CreateBadgePanel({
onClose,
onSuccess,
duplicateFromBatchId,
initialEventId,
}: {
onClose: () => void;
onSuccess?: () => void;
/** When set, loads that batch’s config into the form (duplicate / edit draft). */
duplicateFromBatchId?: string | null;
/** Pre-select event when opened from event home. */
initialEventId?: string;
}) {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
@ -61,20 +182,110 @@ export function CreateBadgePanel({
const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color");
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
defaultLayoutForFields(["name", "title", "org", "qr"], "#0B4DD1", "color"),
);
const [customTexts, setCustomTexts] = useState<{ id: string; text: string }[]>([]);
const [customImages, setCustomImages] = useState<{ id: string; imageUrl: string }[]>([]);
const [imageUploadBusy, setImageUploadBusy] = useState(false);
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
const [badgeTypeLabel, setBadgeTypeLabel] = useState("Attendee");
const [outputFormat, setOutputFormat] = useState<"pdf" | "png" | "jpeg">("pdf");
const [submitting, setSubmitting] = useState(false);
const [submitMsg, setSubmitMsg] = useState<string | null>(null);
const [err, setErr] = useState("");
const [headerBusy, setHeaderBusy] = useState(false);
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
const isDuplicate = Boolean(duplicateFromBatchId);
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
const selectedElement = useMemo(
() => layoutElements.find((e) => e.id === selectedElementId) ?? null,
[layoutElements, selectedElementId],
);
useEffect(() => {
return () => {
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
};
}, [backgroundPreviewUrl]);
useEffect(() => {
setLayoutElements((prev) =>
prev.map((el) => {
let merged = { ...el };
if (el.field === "qr") {
const w = el.w ?? 28;
merged = { ...merged, w, h: w };
} else if (el.field === "photo" && el.w != null) {
merged = { ...merged, h: el.w };
} else if (el.kind === "custom_image" && el.imageAspectRatio) {
const w = el.w ?? 25;
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
}
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) };
}),
);
}, [sizePreset, orientation, customW, customH, aspectRatio]);
useEffect(() => {
if (!duplicateFromBatchId || !listOrgId) {
setHydrating(false);
return;
}
let cancelled = false;
setHydrating(true);
setErr("");
badgeBatchesApi.get(duplicateFromBatchId, listOrgId).then((res) => {
if (cancelled) return;
if (res.error || !res.data) {
setErr(typeof res.error === "string" ? res.error : "Could not load badge to duplicate.");
setHydrating(false);
return;
}
const s = formStateFromBadgeBatch(res.data);
setFormName(s.formName);
setFormEventId(s.formEventId);
setSizePreset(s.sizePreset);
setCustomW(s.customW);
setCustomH(s.customH);
setOrientation(s.orientation);
setIncludeFaculty(s.includeFaculty);
setIncludeParticipants(s.includeParticipants);
setParticipantScope(s.participantScope);
setParticipantListId(s.participantListId);
setBackgroundMode(s.backgroundMode);
setBackgroundColor(s.backgroundColor);
setBackgroundImageUrl(s.backgroundImageUrl);
setBackgroundPreviewUrl("");
setEnabledFields(s.enabledFields);
setLayoutElements(s.layoutElements);
setCustomTexts(s.customTexts);
setCustomImages(s.customImages);
setOutputFormat(s.outputFormat);
setBadgeTypeLabel(s.badgeTypeLabel);
setSelectedElementId(null);
setHydrating(false);
});
return () => {
cancelled = true;
};
}, [duplicateFromBatchId, listOrgId]);
useEffect(() => {
if (initialEventId) setFormEventId(initialEventId);
}, [initialEventId]);
useEffect(() => {
if (!listOrgId) return;
eventsApi.list({ limit: 100, organization: listOrgId }).then((res) => {
if (res.data?.events) {
setEvents(res.data.events);
setFormEventId((id) => id || res.data!.events[0]?._id || "");
setFormEventId((id) => id || initialEventId || res.data!.events[0]?._id || "");
}
});
participantListsApi.list({ organization: listOrgId }).then((res) => {
@ -83,10 +294,99 @@ export function CreateBadgePanel({
setParticipantListId((id) => id || res.data!.lists[0]?._id || "");
}
});
}, [listOrgId]);
}, [listOrgId, initialEventId]);
const toggleField = (id: string) => {
setEnabledFields((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
const next = enabledFields.includes(id)
? enabledFields.filter((x) => x !== id)
: [...enabledFields, id];
setEnabledFields(next);
setLayoutElements((prev) =>
syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, backgroundMode, aspectRatio),
);
if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null);
};
const addCustomText = () => {
if (customTexts.length >= MAX_CUSTOM_TEXT_FIELDS) return;
const id = newCustomTextId();
const text = "Your text";
const nextCustom = [...customTexts, { id, text }];
setCustomTexts(nextCustom);
setLayoutElements((prev) =>
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio),
);
setSelectedElementId(id);
};
const addCustomImage = async (file: File) => {
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
if (!file.type.startsWith("image/")) {
setErr("Choose an image file.");
return;
}
const id = newCustomImageId();
setImageUploadBusy(true);
setErr("");
try {
const imageAspectRatio = await readFileImageAspectRatio(file);
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
if (!r.url) throw new Error("Upload failed");
const nextImages = [...customImages, { id, imageUrl: r.url }];
setCustomImages(nextImages);
setLayoutElements((prev) => [
...prev,
buildCustomImageElement(id, r.url, customImages.length, aspectRatio, imageAspectRatio),
]);
setSelectedElementId(id);
} catch (e) {
setErr(e instanceof Error ? e.message : "Upload failed");
} finally {
setImageUploadBusy(false);
}
};
const removeCustomImage = (id: string) => {
setCustomImages((prev) => prev.filter((c) => c.id !== id));
setLayoutElements((prev) => prev.filter((el) => el.id !== id));
if (selectedElementId === id) setSelectedElementId(null);
};
const removeCustomText = (id: string) => {
setCustomTexts((prev) => prev.filter((c) => c.id !== id));
setLayoutElements((prev) => prev.filter((el) => el.id !== id));
if (selectedElementId === id) setSelectedElementId(null);
};
const updateSelectedElement = (patch: Partial<BadgeLayoutElement>) => {
if (!selectedElementId) return;
setLayoutElements((prev) =>
prev.map((el) => {
if (el.id !== selectedElementId) return el;
const merged = { ...el, ...patch };
if (patch.w != null && (el.field === "qr" || el.kind === "custom_image" || el.field === "photo")) {
return { ...merged, ...applyElementSize(merged, patch.w, aspectRatio) };
}
if (patch.imageAspectRatio != null && el.kind === "custom_image") {
const w = merged.w ?? 25;
return {
...merged,
h: heightPctForImageWidth(w, patch.imageAspectRatio, aspectRatio),
};
}
return merged;
}),
);
if (patch.text !== undefined && selectedElement?.kind === "custom_text") {
setCustomTexts((prev) =>
prev.map((c) => (c.id === selectedElementId ? { ...c, text: patch.text! } : c)),
);
}
if (patch.imageUrl !== undefined && selectedElement?.kind === "custom_image") {
setCustomImages((prev) =>
prev.map((c) => (c.id === selectedElementId ? { ...c, imageUrl: patch.imageUrl! } : c)),
);
}
};
const onHeaderFile = async (file: File | null | undefined) => {
@ -94,6 +394,8 @@ export function CreateBadgePanel({
setErr("Choose an image file.");
return;
}
const local = URL.createObjectURL(file);
setBackgroundPreviewUrl(local);
setHeaderBusy(true);
setErr("");
try {
@ -136,10 +438,32 @@ export function CreateBadgePanel({
setErr("Upload a background image or switch to solid colour.");
return;
}
if (!enabledFields.length) {
setErr("Select at least one badge field.");
const hasCustom =
layoutElements.some((el) => el.kind === "custom_text") ||
layoutElements.some((el) => el.kind === "custom_image");
if (!enabledFields.length && !hasCustom) {
setErr("Select at least one badge field, fixed text, or fixed image.");
return;
}
for (const fid of enabledFields) {
if (!layoutElements.some((el) => el.kind === "field" && el.field === fid)) {
setErr(`Layout missing for field: ${fid}`);
return;
}
}
for (const el of layoutElements) {
if (el.kind === "custom_text" && !String(el.text || customTexts.find((c) => c.id === el.id)?.text || "").trim()) {
setErr("Fixed text fields must have content.");
return;
}
if (el.kind === "custom_image") {
const url = el.imageUrl || customImages.find((c) => c.id === el.id)?.imageUrl || "";
if (!url.trim()) {
setErr("Fixed image fields must have an uploaded image.");
return;
}
}
}
if (sizePreset === "custom") {
const w = parseFloat(customW);
const h = parseFloat(customH);
@ -149,6 +473,15 @@ export function CreateBadgePanel({
}
}
const layoutPayload = layoutElements.map((el) => ({
...el,
text: el.kind === "custom_text" ? (el.text ?? customTexts.find((c) => c.id === el.id)?.text ?? "") : undefined,
imageUrl:
el.kind === "custom_image"
? (el.imageUrl ?? customImages.find((c) => c.id === el.id)?.imageUrl ?? "")
: undefined,
}));
const payload = {
name: formName.trim(),
organization: listOrgId,
@ -166,6 +499,9 @@ export function CreateBadgePanel({
backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "",
backgroundColor,
fields: enabledFields,
layout: layoutPayload,
customTexts: customTexts.map(({ id, text }) => ({ id, text })),
customImages: customImages.map(({ id, imageUrl }) => ({ id, imageUrl })),
outputFormat,
badgeTypeLabel: badgeTypeLabel.trim() || "Attendee",
};
@ -187,247 +523,328 @@ export function CreateBadgePanel({
return (
<PanelShell
onClose={onClose}
title="Request badge export"
title={isDuplicate ? "Duplicate badge export" : "Request badge export"}
breadcrumb="Design · Badges"
description="Faculty = speakers linked to sessions for the selected event. A zip file is built on the server and uploaded when ready."
description={
isDuplicate
? "Settings copied from the selected export. Adjust anything, then submit to queue a new batch."
: "Faculty = speakers linked to sessions for the selected event. A zip file is built on the server and uploaded when ready."
}
completedCount={acc.done.size}
totalSections={TOTAL}
ctaLabel="Submit request"
ctaDisabled={submitting}
ctaDisabled={submitting || hydrating}
onCtaClick={handleSubmit}
panelWidth="min(1120px, 100vw)"
>
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px" }}>{err}</p>}
{submitMsg && (
<p style={{ color: "var(--foreground)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px", background: "var(--accent)", padding: 12, borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}>
{submitMsg}
{hydrating && (
<p style={{ color: "var(--muted)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px" }}>
Loading badge settings…
</p>
)}
<AccordionSection
n={1}
title="Details & recipients"
subtitle="Event, size, orientation, who appears on badges"
open={acc.open === 1}
completed={acc.done.has(1)}
onToggle={() => acc.toggle(1)}
onSave={() => acc.advance(1)}
nextLabel="Design"
>
<Grid cols={2}>
<Field span2={false}>
<Label text="Export name" required />
<input
style={IS}
placeholder="e.g. NAPCON 2026 — All badges"
value={formName}
onChange={(e) => setFormName(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
<div style={{ display: "flex", minHeight: "100%" }}>
<div style={{ flex: "0 0 42%", maxWidth: "42%", borderRight: "1px solid var(--border)", alignSelf: "flex-start", position: "sticky", top: 0, overflow: "visible", zIndex: 2 }}>
<div style={{ position: "relative", overflow: "visible" }}>
<BadgeDesignerPreview
aspectRatio={aspectRatio}
badgeAspectRatio={aspectRatio}
backgroundMode={backgroundMode}
backgroundColor={backgroundColor}
backgroundImageUrl={displayBgUrl}
layoutElements={layoutElements}
customImages={customImages}
badgeTypeLabel={badgeTypeLabel}
selectedId={selectedElementId}
onSelect={setSelectedElementId}
onLayoutChange={setLayoutElements}
/>
<BadgeFieldInspector
element={selectedElement}
customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text}
customImages={customImages}
badgeAspectRatio={aspectRatio}
onChange={updateSelectedElement}
onRemoveCustom={
selectedElement?.kind === "custom_text"
? () => removeCustomText(selectedElement.id)
: selectedElement?.kind === "custom_image"
? () => removeCustomImage(selectedElement.id)
: undefined
}
onCustomImageUploaded={(id, url, imageAspectRatio) => {
setCustomImages((prev) => prev.map((c) => (c.id === id ? { ...c, imageUrl: url } : c)));
updateSelectedElement({ imageUrl: url, imageAspectRatio });
}}
/>
</Field>
<Field span2={false}>
<Label text="Associated event" required />
<SelectWrap>
<select style={SS} value={formEventId} onChange={(e) => setFormEventId(e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">Select…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field span2={false}>
<Label text="Badge size" required />
<SelectWrap>
<select
style={SS}
value={sizePreset}
onChange={(e) => setSizePreset(e.target.value as typeof sizePreset)}
onFocus={focusIn}
onBlur={focusOut}
>
{BADGE_SIZES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</SelectWrap>
</Field>
{sizePreset === "custom" && (
<>
<Field>
<Label text="Width (mm)" required />
<input style={IS} value={customW} onChange={(e) => setCustomW(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
<Field>
<Label text="Height (mm)" required />
<input style={IS} value={customH} onChange={(e) => setCustomH(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
</>
)}
</Grid>
<Field>
<Label text="Orientation" />
<RadioGroup
name="bdOrientation"
value={orientation}
onChange={(v) => setOrientation(v as "portrait" | "landscape")}
options={[
{ label: "Portrait", value: "portrait" },
{ label: "Landscape", value: "landscape" },
]}
/>
</Field>
<Field>
<Label text="Include on export" required />
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input type="checkbox" checked={includeFaculty} onChange={(e) => setIncludeFaculty(e.target.checked)} />
Faculty (speakers linked to this event&apos;s sessions)
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input type="checkbox" checked={includeParticipants} onChange={(e) => setIncludeParticipants(e.target.checked)} />
Participants
</label>
</div>
</Field>
{includeParticipants && (
<Field>
<Label text="Participants" />
<RadioGroup
name="partScope"
value={participantScope}
onChange={(v) => setParticipantScope(v as "all" | "participant_list")}
options={[
{ label: "All registered for this event", value: "all" },
{ label: "Specific contact list", value: "participant_list" },
]}
/>
{participantScope === "participant_list" && (
<SelectWrap>
<select style={{ ...SS, marginTop: 8 }} value={participantListId} onChange={(e) => setParticipantListId(e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">Select list…</option>
{lists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
</SelectWrap>
)}
</Field>
)}
<Field>
<Label text="Badge type label (participants)" />
<input style={IS} value={badgeTypeLabel} onChange={(e) => setBadgeTypeLabel(e.target.value)} placeholder="Attendee" onFocus={focusIn} onBlur={focusOut} />
<HelperText text="Shown when “Badge type tag” is enabled; faculty use their speaker role when available." />
</Field>
</AccordionSection>
</div>
<AccordionSection
n={2}
title="Design"
subtitle="Background and fields on the badge"
open={acc.open === 2}
completed={acc.done.has(2)}
onToggle={() => acc.toggle(2)}
onSave={() => acc.advance(2)}
nextLabel="Output"
>
<Field>
<Label text="Background" required />
<RadioGroup
name="bgMode"
value={backgroundMode}
onChange={(v) => setBackgroundMode(v as "image" | "color")}
options={[
{ label: "Solid colour", value: "color" },
{ label: "Image", value: "image" },
]}
/>
</Field>
{backgroundMode === "color" && (
<Field>
<Label text="Colour" />
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</div>
</Field>
)}
{backgroundMode === "image" && (
<Field>
<Label text="Background image" />
<input style={IS} value={backgroundImageUrl} onChange={(e) => setBackgroundImageUrl(e.target.value)} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
<input
type="file"
accept="image/*"
style={{ marginTop: 8, fontSize: "var(--text-xs)" }}
disabled={headerBusy}
onChange={(e) => void onHeaderFile(e.target.files?.[0])}
/>
<HelperText text="Must be reachable by the server when the worker runs (public HTTPS)." />
</Field>
)}
<Field>
<Label text="Badge fields" />
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{BADGE_FIELDS.map((f) => {
const on = enabledFields.includes(f.id);
return (
<div style={{ flex: 1, minWidth: 0 }}>
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px" }}>{err}</p>}
{submitMsg && (
<p style={{ color: "var(--foreground)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px", background: "var(--accent)", padding: 12, borderRadius: "var(--radius-button)", border: "1px solid var(--border)" }}>
{submitMsg}
</p>
)}
<AccordionSection
n={1}
title="Details & recipients"
subtitle="Event, size, orientation, who appears on badges"
open={acc.open === 1}
completed={acc.done.has(1)}
onToggle={() => acc.toggle(1)}
onSave={() => acc.advance(1)}
nextLabel="Design"
>
<Grid cols={2}>
<Field span2={false}>
<Label text="Export name" required />
<input
style={IS}
placeholder="e.g. NAPCON 2026 — All badges"
value={formName}
onChange={(e) => setFormName(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field span2={false}>
<Label text="Associated event" required />
<SelectWrap>
<select style={SS} value={formEventId} onChange={(e) => setFormEventId(e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">Select…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field span2={false}>
<Label text="Badge size" required />
<SelectWrap>
<select
style={SS}
value={sizePreset}
onChange={(e) => setSizePreset(e.target.value as typeof sizePreset)}
onFocus={focusIn}
onBlur={focusOut}
>
{BADGE_SIZES.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</SelectWrap>
</Field>
{sizePreset === "custom" && (
<>
<Field>
<Label text="Width (mm)" required />
<input style={IS} value={customW} onChange={(e) => setCustomW(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
<Field>
<Label text="Height (mm)" required />
<input style={IS} value={customH} onChange={(e) => setCustomH(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</Field>
</>
)}
</Grid>
<Field>
<Label text="Orientation" />
<RadioGroup
name="bdOrientation"
value={orientation}
onChange={(v) => setOrientation(v as "portrait" | "landscape")}
options={[
{ label: "Portrait", value: "portrait" },
{ label: "Landscape", value: "landscape" },
]}
/>
</Field>
<Field>
<Label text="Include on export" required />
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input type="checkbox" checked={includeFaculty} onChange={(e) => setIncludeFaculty(e.target.checked)} />
Faculty (speakers linked to this event&apos;s sessions)
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input type="checkbox" checked={includeParticipants} onChange={(e) => setIncludeParticipants(e.target.checked)} />
Participants
</label>
</div>
</Field>
{includeParticipants && (
<Field>
<Label text="Participants" />
<RadioGroup
name="partScope"
value={participantScope}
onChange={(v) => setParticipantScope(v as "all" | "participant_list")}
options={[
{ label: "All registered for this event", value: "all" },
{ label: "Specific contact list", value: "participant_list" },
]}
/>
{participantScope === "participant_list" && (
<SelectWrap>
<select style={{ ...SS, marginTop: 8 }} value={participantListId} onChange={(e) => setParticipantListId(e.target.value)} onFocus={focusIn} onBlur={focusOut}>
<option value="">Select list…</option>
{lists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
</SelectWrap>
)}
</Field>
)}
<Field>
<Label text="Badge type label (participants)" />
<input style={IS} value={badgeTypeLabel} onChange={(e) => setBadgeTypeLabel(e.target.value)} placeholder="Attendee" onFocus={focusIn} onBlur={focusOut} />
<HelperText text="Shown when “Badge type tag” is enabled; faculty use their speaker role when available." />
</Field>
</AccordionSection>
<AccordionSection
n={2}
title="Design"
subtitle="Background and fields on the badge"
open={acc.open === 2}
completed={acc.done.has(2)}
onToggle={() => acc.toggle(2)}
onSave={() => acc.advance(2)}
nextLabel="Output"
>
<Field>
<Label text="Background" required />
<RadioGroup
name="bgMode"
value={backgroundMode}
onChange={(v) => setBackgroundMode(v as "image" | "color")}
options={[
{ label: "Solid colour", value: "color" },
{ label: "Image", value: "image" },
]}
/>
</Field>
{backgroundMode === "color" && (
<Field>
<Label text="Colour" />
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</div>
</Field>
)}
{backgroundMode === "image" && (
<Field>
<Label text="Background image" />
<input style={IS} value={backgroundImageUrl} onChange={(e) => setBackgroundImageUrl(e.target.value)} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
<BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
<HelperText text="Must be reachable by the server when the worker runs (public HTTPS)." />
</Field>
)}
<Field>
<Label text="Badge fields" />
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{BADGE_FIELDS.map((f) => {
const on = enabledFields.includes(f.id);
return (
<button
key={f.id}
type="button"
onClick={() => toggleField(f.id)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: `1px solid ${on ? "var(--primary)" : "var(--border)"}`,
background: on ? "var(--accent)" : "var(--card)",
cursor: "pointer",
textAlign: "left",
}}
>
<span style={{ color: "var(--muted)" }}>{f.icon}</span>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>{f.label}</span>
</button>
);
})}
</div>
</Field>
<Field>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<Label text="Fixed text fields" />
<button
key={f.id}
type="button"
onClick={() => toggleField(f.id)}
disabled={customTexts.length >= MAX_CUSTOM_TEXT_FIELDS}
onClick={addCustomText}
style={{
display: "flex",
display: "inline-flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
gap: 6,
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: `1px solid ${on ? "var(--primary)" : "var(--border)"}`,
background: on ? "var(--accent)" : "var(--card)",
cursor: "pointer",
textAlign: "left",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: customTexts.length >= MAX_CUSTOM_TEXT_FIELDS ? "not-allowed" : "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
opacity: customTexts.length >= MAX_CUSTOM_TEXT_FIELDS ? 0.6 : 1,
}}
>
<span style={{ color: "var(--muted)" }}>{f.icon}</span>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>{f.label}</span>
<Plus size={12} /> Add fixed text
</button>
);
})}
</div>
</Field>
</AccordionSection>
</div>
<HelperText text={`Up to ${MAX_CUSTOM_TEXT_FIELDS} custom labels. Click a field on the preview to style it in the panel beside the preview.`} />
</Field>
<Field>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
<Label text="Fixed images" />
<FixedImageAddButton
busy={imageUploadBusy}
disabled={customImages.length >= MAX_CUSTOM_IMAGE_FIELDS}
onFile={(f) => void addCustomImage(f)}
/>
</div>
<HelperText text={`Up to ${MAX_CUSTOM_IMAGE_FIELDS} logos or graphics. Keeps the uploaded width/height ratio — drag and resize on preview.`} />
</Field>
</AccordionSection>
<AccordionSection
n={3}
title="Output format"
subtitle="Files are zipped after generation"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={() => acc.advance(3)}
nextLabel="Submit"
>
<Field>
<Label text="Format" required />
<RadioGroup
name="outFmt"
value={outputFormat}
onChange={(v) => setOutputFormat(v as "pdf" | "png" | "jpeg")}
options={[
{ label: "PDF (multi-page, one badge per page)", value: "pdf" },
{ label: "PNG (one file per person)", value: "png" },
{ label: "JPEG (one file per person)", value: "jpeg" },
]}
/>
</Field>
<HelperText text="Run npm run badge-batch-worker on the server so jobs complete. AWS S3/CloudFront must be configured." />
</AccordionSection>
<AccordionSection
n={3}
title="Output format"
subtitle="Files are zipped after generation"
open={acc.open === 3}
completed={acc.done.has(3)}
onToggle={() => acc.toggle(3)}
onSave={() => acc.advance(3)}
nextLabel="Submit"
>
<Field>
<Label text="Format" required />
<RadioGroup
name="outFmt"
value={outputFormat}
onChange={(v) => setOutputFormat(v as "pdf" | "png" | "jpeg")}
options={[
{ label: "PDF (multi-page, one badge per page)", value: "pdf" },
{ label: "PNG (one file per person)", value: "png" },
{ label: "JPEG (one file per person)", value: "jpeg" },
]}
/>
</Field>
<HelperText text="Run npm run badge-batch-worker on the server so jobs complete. AWS S3/CloudFront must be configured." />
</AccordionSection>
</div>
</div>
</PanelShell>
);
}

View File

@ -84,6 +84,19 @@ function escapeHtml(s: string) {
.replace(/"/g, "&quot;");
}
function publicFormHref(formId: string, eventId?: string): string {
const fid = String(formId || "").trim();
if (!fid) return "";
const base =
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) ||
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" && import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
(typeof window !== "undefined" ? window.location.origin : "http://localhost:5173");
const root = base.replace(/\/$/, "");
const eid = eventId != null ? String(eventId).trim() : "";
if (eid) return `${root}/event/${eid}/form/${fid}`;
return `${root}/form/${fid}`;
}
function resolveCtaHref(
b: Extract<EmailerContentBlock, { type: "cta" }>,
targets: { forms: { id: string; href: string }[]; microsites: { id: string; href: string }[] },
@ -91,7 +104,8 @@ function resolveCtaHref(
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
if (b.linkKind === "form") {
const f = targets.forms.find((x) => x.id === b.formId);
return (f?.href && f.href.trim()) || "#";
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
return href || "#";
}
if (b.linkKind === "microsite") {
const m = targets.microsites.find((x) => x.id === b.micrositeId);

View File

@ -307,6 +307,7 @@ function typeFromApi(t: string, opts?: string[], qTypes?: string[]): string {
return "Multiple Choice";
}
if (t === "checkbox") return qTypes?.includes("Yes / No") ? "Yes / No" : "Checkboxes";
if (t === "textarea") return qTypes?.includes("Long Text") ? "Long Text" : "Text";
if (t === "text") return qTypes?.includes("Short Text") ? "Short Text" : "Text";
if (t === "email") return "Email";
if (t === "phone") return "Phone";
@ -329,7 +330,8 @@ function toApiField(q: Question, isFeedbackOrOther: boolean): FormField {
if (q.type === "Multiple Choice") return { label, type: "select", required, options: choiceOpts };
if (q.type === "Radio") return { label, type: "radio", required, options: choiceOpts };
if (q.type === "Checkboxes") return { label, type: "checkboxes", required, options: choiceOpts };
if (q.type === "Short Text" || q.type === "Long Text") return { label, type: "text", required };
if (q.type === "Short Text") return { label, type: "text", required };
if (q.type === "Long Text") return { label, type: "textarea", required };
if (q.type === "Yes / No") return { label, type: "checkbox", required };
const regType = REG_TYPE_TO_API[q.type];
if (regType) {
@ -411,12 +413,15 @@ export function CreateFormPanel({
eventId,
formId,
mode,
defaultFormName,
onClose,
onSuccess,
}: {
eventId?: string;
formId?: string;
mode: FormMode;
/** Prefill name when creating a new form (no formId). */
defaultFormName?: string;
onClose: () => void;
onSuccess: () => void;
}) {
@ -467,6 +472,11 @@ export function CreateFormPanel({
if (!formId && eventId) setSelectedEventId(eventId);
}, [formId, eventId]);
useEffect(() => {
if (formId || !defaultFormName?.trim()) return;
setForm((prev) => (prev.name.trim() ? prev : { ...prev, name: defaultFormName.trim() }));
}, [formId, defaultFormName]);
useEffect(() => {
if (!isReg) return;
if (!listOrgId) {

View File

@ -0,0 +1,136 @@
/**
* Map a saved BadgeBatch (config) into CreateBadgePanel form state.
*/
import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches";
import type { BadgeLayoutElement } from "./badgeLayout";
import { defaultLayoutForFields } from "./badgeLayout";
export type BadgeFormState = {
formName: string;
formEventId: string;
sizePreset: "a6" | "a7" | "4x3in" | "4x6in" | "custom";
customW: string;
customH: string;
orientation: "portrait" | "landscape";
includeFaculty: boolean;
includeParticipants: boolean;
participantScope: "all" | "participant_list";
participantListId: string;
backgroundMode: "image" | "color";
backgroundColor: string;
backgroundImageUrl: string;
enabledFields: string[];
layoutElements: BadgeLayoutElement[];
customTexts: { id: string; text: string }[];
customImages: { id: string; imageUrl: string }[];
outputFormat: "pdf" | "png" | "jpeg";
badgeTypeLabel: string;
};
function eventIdFromBatch(batch: BadgeBatchRecord): string {
const ev = batch.event;
if (typeof ev === "string") return ev;
if (ev && typeof ev === "object" && "_id" in ev) return String(ev._id);
return "";
}
function duplicateExportName(name: string): string {
const base = (name || "Badge export").trim();
const copy = `Copy of ${base}`;
return copy.length > 120 ? `${copy.slice(0, 117)}…` : copy;
}
function asConfig(batch: BadgeBatchRecord): Record<string, unknown> {
return (batch.config && typeof batch.config === "object" ? batch.config : {}) as Record<string, unknown>;
}
function str(v: unknown, fallback = ""): string {
return typeof v === "string" ? v : fallback;
}
function bool(v: unknown, fallback: boolean): boolean {
return typeof v === "boolean" ? v : fallback;
}
function numStr(v: unknown, fallback: string): string {
if (typeof v === "number" && Number.isFinite(v)) return String(v);
return fallback;
}
function sizePresetFromConfig(c: Record<string, unknown>): BadgeFormState["sizePreset"] {
const p = str(c.sizePreset, "a6");
if (p === "a7" || p === "4x3in" || p === "4x6in" || p === "custom") return p;
return "a6";
}
function layoutFromConfig(c: Record<string, unknown>): BadgeLayoutElement[] {
const raw = c.layout;
if (!Array.isArray(raw) || raw.length === 0) return [];
return raw
.filter((el): el is BadgeLayoutElementPayload => el != null && typeof el === "object" && typeof (el as BadgeLayoutElementPayload).id === "string")
.map((el) => ({ ...el })) as BadgeLayoutElement[];
}
function customTextsFromConfig(c: Record<string, unknown>, layout: BadgeLayoutElement[]): { id: string; text: string }[] {
const fromConfig = Array.isArray(c.customTexts)
? c.customTexts
.filter((x): x is { id: string; text: string } => x != null && typeof x === "object" && typeof (x as { id: string }).id === "string")
.map((x) => ({ id: String(x.id), text: String((x as { text?: string }).text ?? "") }))
: [];
if (fromConfig.length) return fromConfig;
return layout
.filter((el) => el.kind === "custom_text")
.map((el) => ({ id: el.id, text: str(el.text, "Your text") }));
}
function customImagesFromConfig(c: Record<string, unknown>, layout: BadgeLayoutElement[]): { id: string; imageUrl: string }[] {
const fromConfig = Array.isArray(c.customImages)
? c.customImages
.filter((x): x is { id: string; imageUrl: string } => x != null && typeof x === "object" && typeof (x as { id: string }).id === "string")
.map((x) => ({ id: String(x.id), imageUrl: String((x as { imageUrl?: string }).imageUrl ?? "") }))
: [];
if (fromConfig.length) return fromConfig;
return layout
.filter((el) => el.kind === "custom_image")
.map((el) => ({ id: el.id, imageUrl: str(el.imageUrl, "") }))
.filter((x) => x.imageUrl);
}
export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState {
const c = asConfig(batch);
const bgMode = str(c.backgroundMode, "color") === "image" ? "image" : "color";
const bgColor = str(c.backgroundColor, "#0B4DD1");
const fields = Array.isArray(c.fields) ? c.fields.map((f) => String(f)) : ["name", "title", "org", "qr"];
let layoutElements = layoutFromConfig(c);
if (!layoutElements.length && fields.length) {
layoutElements = defaultLayoutForFields(fields, bgColor, bgMode);
}
const customTexts = customTextsFromConfig(c, layoutElements);
const customImages = customImagesFromConfig(c, layoutElements);
const scope = str(c.participantScope, "all") === "participant_list" ? "participant_list" : "all";
const out = str(c.outputFormat, "pdf");
const outputFormat: BadgeFormState["outputFormat"] =
out === "png" || out === "jpeg" ? out : "pdf";
return {
formName: duplicateExportName(batch.name),
formEventId: eventIdFromBatch(batch),
sizePreset: sizePresetFromConfig(c),
customW: numStr(c.widthMm, "105"),
customH: numStr(c.heightMm, "148"),
orientation: str(c.orientation, "portrait") === "landscape" ? "landscape" : "portrait",
includeFaculty: bool(c.includeFaculty, true),
includeParticipants: bool(c.includeParticipants, true),
participantScope: scope,
participantListId: c.participantListId != null ? String(c.participantListId) : "",
backgroundMode: bgMode,
backgroundColor: bgColor,
backgroundImageUrl: str(c.backgroundImageUrl, ""),
enabledFields: fields,
layoutElements,
customTexts,
customImages,
outputFormat,
badgeTypeLabel: str(c.badgeTypeLabel, "Attendee"),
};
}

View File

@ -0,0 +1,412 @@
/**
* Badge layout types and helpers — shared between preview and API payload.
*/
export type BadgeFieldId = "name" | "title" | "org" | "badge_type" | "qr" | "photo";
export type BadgeTextAlign = "left" | "center" | "right";
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
export type BadgeLayoutElement = {
id: string;
kind: BadgeLayoutKind;
field?: BadgeFieldId;
text?: string;
imageUrl?: string;
/** Uploaded image width ÷ height */
imageAspectRatio?: number;
x: number;
y: number;
w?: number;
h?: number;
fontSizePct?: number;
fontFamily?: string;
color?: string;
fontWeight?: "normal" | "bold";
/** Default true — span full badge width with centered text */
fullWidthCenter?: boolean;
/** Default true — reduce font size when text overflows */
shrinkToFit?: boolean;
textAlign?: BadgeTextAlign;
};
export const DEFAULT_TEXT_LAYOUT = {
fullWidthCenter: true,
shrinkToFit: true,
textAlign: "center" as BadgeTextAlign,
x: 0,
w: 100,
};
/** Treat undefined as true for backward-compatible defaults */
export function usesFullWidthCenter(el: BadgeLayoutElement): boolean {
return el.fullWidthCenter !== false;
}
export function usesShrinkToFit(el: BadgeLayoutElement): boolean {
return el.shrinkToFit !== false;
}
export function textAlignFor(el: BadgeLayoutElement): BadgeTextAlign {
if (usesFullWidthCenter(el)) return el.textAlign ?? "center";
return el.textAlign ?? "left";
}
export function isResizableElement(el: BadgeLayoutElement): boolean {
return el.field === "qr" || el.field === "photo" || el.kind === "custom_image";
}
export function isSquareResizable(el: BadgeLayoutElement): boolean {
return el.field === "qr";
}
export function elementSizePct(el: BadgeLayoutElement): number {
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
}
export function clampElementSize(el: BadgeLayoutElement, w: number): number {
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
}
export function clampHeightPct(h: number): number {
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, h));
}
/** Height % of badge for a given width % preserving uploaded image aspect */
export function heightPctForImageWidth(
w: number,
imageAspectRatio: number,
badgeAspectRatio: number,
): number {
const ar = imageAspectRatio > 0 ? imageAspectRatio : 1;
return clampHeightPct((w * badgeAspectRatio) / ar);
}
export function loadImageAspectRatio(url: string): Promise<number> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img.naturalWidth > 0 && img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 1);
img.onerror = () => resolve(1);
img.src = url;
});
}
export function readFileImageAspectRatio(file: File): Promise<number> {
const blob = URL.createObjectURL(file);
return loadImageAspectRatio(blob).finally(() => URL.revokeObjectURL(blob));
}
/** Apply new width/height and re-clamp position so element stays on badge */
export function applyElementSize(
el: BadgeLayoutElement,
w: number,
badgeAspectRatio: number,
): Partial<BadgeLayoutElement> {
const size = clampElementSize(el, w);
const patch: Partial<BadgeLayoutElement> = { w: size };
if (isSquareResizable(el) || el.field === "photo") {
patch.h = size;
} else if (el.kind === "custom_image") {
const ar = el.imageAspectRatio ?? 1;
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
}
const merged = { ...el, ...patch };
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y) };
}
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
export function elementBoundsPct(el: BadgeLayoutElement): { w: number; h: number } {
if (el.field === "qr") {
const s = elementSizePct(el);
return { w: s, h: s };
}
if (el.kind === "custom_image") {
return { w: el.w ?? 25, h: el.h ?? 25 };
}
if (el.field === "photo") {
const s = elementSizePct(el);
return { w: s, h: s };
}
if (isTextElement(el)) {
if (usesFullWidthCenter(el)) {
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
}
return { w: el.w ?? 72, h: Math.max(4, (el.fontSizePct ?? 5) * 2.5) };
}
return { w: 0, h: 0 };
}
export function clampElementPosition(
el: BadgeLayoutElement,
x: number,
y: number,
): { x: number; y: number } {
const bounds = elementBoundsPct(el);
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
let cx = Math.min(maxX, Math.max(0, x));
const cy = Math.min(maxY, Math.max(0, y));
if (isTextElement(el) && usesFullWidthCenter(el)) cx = 0;
return { x: cx, y: cy };
}
export const BADGE_FONT_FAMILIES = [
{ id: "Inter", label: "Inter", css: "var(--font-inter), Inter, sans-serif" },
{ id: "Noto Sans", label: "Noto Sans", css: "var(--font-noto-sans), 'Noto Sans', sans-serif" },
{ id: "Arial", label: "Arial", css: "Arial, sans-serif" },
{ id: "Georgia", label: "Georgia", css: "Georgia, serif" },
{ id: "Times New Roman", label: "Times New Roman", css: "'Times New Roman', Times, serif" },
] as const;
export const DEFAULT_TEXT_COLOR = "#0f172a";
export const MAX_CUSTOM_TEXT_FIELDS = 5;
export const MAX_CUSTOM_IMAGE_FIELDS = 5;
export const RESIZABLE_SIZE_MIN = 8;
export const RESIZABLE_SIZE_MAX = 65;
const PRESETS_MM: Record<string, { w: number; h: number }> = {
a6: { w: 105, h: 148 },
a7: { w: 74, h: 105 },
"4x3in": { w: 101.6, h: 76.2 },
"4x6in": { w: 101.6, h: 152.4 },
};
export function getBadgeAspectRatio(
sizePreset: "a6" | "a7" | "4x3in" | "4x6in" | "custom",
orientation: "portrait" | "landscape",
customW?: string,
customH?: string,
): number {
let w: number;
let h: number;
if (sizePreset === "custom") {
w = parseFloat(customW || "105") || 105;
h = parseFloat(customH || "148") || 148;
} else {
const p = PRESETS_MM[sizePreset] || PRESETS_MM.a6;
w = p.w;
h = p.h;
}
if (orientation === "landscape") [w, h] = [h, w];
return w / h;
}
export function contrastTextColor(hex: string): string {
const h = (hex || "#000000").replace("#", "");
const r = parseInt(h.slice(0, 2), 16) || 0;
const g = parseInt(h.slice(2, 4), 16) || 0;
const b = parseInt(h.slice(4, 6), 16) || 0;
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.55 ? "#0f172a" : "#ffffff";
}
function textDefaults(bgColor: string, bgMode: "color" | "image") {
const color = bgMode === "color" ? contrastTextColor(bgColor) : DEFAULT_TEXT_COLOR;
return {
color,
fontFamily: "Inter",
fontWeight: "normal" as const,
...DEFAULT_TEXT_LAYOUT,
};
}
function defaultElementForField(fieldId: BadgeFieldId, bgColor: string, bgMode: "color" | "image"): BadgeLayoutElement {
const base = { kind: "field" as const, field: fieldId, id: fieldId, ...textDefaults(bgColor, bgMode) };
switch (fieldId) {
case "name":
return { ...base, y: 8, fontSizePct: 7, fontWeight: "bold" };
case "title":
return { ...base, y: 22, fontSizePct: 4.5 };
case "org":
return { ...base, y: 32, fontSizePct: 4 };
case "badge_type":
return { ...base, y: 44, fontSizePct: 3.5 };
case "photo":
return { kind: "field", field: fieldId, id: fieldId, x: 74, y: 6, w: 18, h: 18 };
case "qr":
return { kind: "field", field: fieldId, id: fieldId, x: 36, y: 68, w: 28, h: 28 };
default:
return { ...base, y: 8, fontSizePct: 5 };
}
}
export function defaultLayoutForFields(
fieldIds: string[],
bgColor: string,
bgMode: "color" | "image",
): BadgeLayoutElement[] {
return fieldIds
.filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id))
.map((id) => defaultElementForField(id, bgColor, bgMode));
}
export function defaultCustomTextElement(
id: string,
text: string,
bgColor: string,
bgMode: "color" | "image",
index: number,
): BadgeLayoutElement {
return {
id,
kind: "custom_text",
text,
y: 55 + index * 8,
fontSizePct: 4,
fontWeight: "normal",
...textDefaults(bgColor, bgMode),
};
}
export function newCustomTextId(): string {
const hex = Math.random().toString(16).slice(2, 10);
return `custom_${hex}`;
}
export function newCustomImageId(): string {
const hex = Math.random().toString(16).slice(2, 10);
return `cimg_${hex}`;
}
export function buildCustomImageElement(
id: string,
imageUrl: string,
index: number,
badgeAspectRatio: number,
imageAspectRatio: number,
): BadgeLayoutElement {
const w = 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
return {
id,
kind: "custom_image",
imageUrl,
imageAspectRatio,
x: 10 + index * 4,
y: 50 + index * 6,
w,
h,
};
}
export function imageUrlForElement(
el: BadgeLayoutElement,
customImages: { id: string; imageUrl: string }[],
): string {
return (el.imageUrl ?? customImages.find((c) => c.id === el.id)?.imageUrl ?? "").trim();
}
export function fontCss(family?: string): string {
const f = BADGE_FONT_FAMILIES.find((x) => x.id === family);
return f?.css ?? BADGE_FONT_FAMILIES[0].css;
}
export type BadgePreviewSample = {
name: string;
title: string;
org: string;
badgeType: string;
};
export function samplePreviewData(badgeTypeLabel: string): BadgePreviewSample {
return {
name: "Jane Doe",
title: "Keynote Speaker",
org: "Acme Inc.",
badgeType: badgeTypeLabel.trim() || "Attendee",
};
}
const PREVIEW_AVATAR_COLORS = ["#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#be185d", "#0d9488", "#b45309"];
export function previewInitialsFromName(name: string): string {
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase();
return (parts[0]?.charAt(0) || "J").toUpperCase();
}
export function previewAvatarColor(seed: string): string {
let n = 0;
for (let i = 0; i < seed.length; i++) n += seed.charCodeAt(i);
return PREVIEW_AVATAR_COLORS[n % PREVIEW_AVATAR_COLORS.length];
}
export function previewTextForElement(
el: BadgeLayoutElement,
sample: BadgePreviewSample,
): string {
if (el.kind === "custom_text") return el.text?.trim() || "Your text";
switch (el.field) {
case "name":
return sample.name;
case "title":
return sample.title;
case "org":
return sample.org;
case "badge_type":
return sample.badgeType;
default:
return "";
}
}
export function isTextElement(el: BadgeLayoutElement): boolean {
if (el.kind === "custom_text") return true;
return el.field === "name" || el.field === "title" || el.field === "org" || el.field === "badge_type";
}
export function syncLayoutWithFields(
prev: BadgeLayoutElement[],
enabledFields: string[],
customTexts: { id: string; text: string }[],
customImages: { id: string; imageUrl: string }[],
bgColor: string,
bgMode: "color" | "image",
badgeAspectRatio: number,
): BadgeLayoutElement[] {
const fieldSet = new Set(enabledFields);
const customTextMap = new Map(customTexts.map((c) => [c.id, c.text]));
const customImageMap = new Map(customImages.map((c) => [c.id, c.imageUrl]));
const keptFields = prev.filter((el) => el.kind === "field" && el.field && fieldSet.has(el.field));
const keptCustomText = prev.filter((el) => el.kind === "custom_text" && customTextMap.has(el.id));
const keptCustomImage = prev.filter((el) => el.kind === "custom_image" && customImageMap.has(el.id));
const existingFieldIds = new Set(keptFields.map((e) => e.field));
const addedFields = enabledFields
.filter((id): id is BadgeFieldId => ["name", "title", "org", "badge_type", "qr", "photo"].includes(id))
.filter((id) => !existingFieldIds.has(id))
.map((id) => defaultElementForField(id, bgColor, bgMode));
const existingTextIds = new Set(keptCustomText.map((e) => e.id));
const addedCustomText = customTexts
.filter((c) => !existingTextIds.has(c.id))
.map((c, i) => defaultCustomTextElement(c.id, c.text, bgColor, bgMode, i));
const existingImageIds = new Set(keptCustomImage.map((e) => e.id));
const addedCustomImage = customImages
.filter((c) => !existingImageIds.has(c.id))
.map((c, i) => buildCustomImageElement(c.id, c.imageUrl, i, badgeAspectRatio, 1));
const mergedCustomText = [
...keptCustomText.map((el) => ({ ...el, text: customTextMap.get(el.id) ?? el.text })),
...addedCustomText,
];
const mergedCustomImage = [
...keptCustomImage.map((el) => {
const imageUrl = customImageMap.get(el.id) ?? el.imageUrl;
const ar = el.imageAspectRatio ?? 1;
const w = el.w ?? 25;
return {
...el,
imageUrl,
h: heightPctForImageWidth(w, ar, badgeAspectRatio),
};
}),
...addedCustomImage,
];
return [...keptFields, ...addedFields, ...mergedCustomText, ...mergedCustomImage];
}

View File

@ -0,0 +1,94 @@
import React from "react";
export type ParticipantBulkVariant = "portfolio" | "event";
export function ParticipantBulkActionBar({
selectedCount,
busy,
variant,
onAddTags,
onRemoveTags,
onAssignEvents,
onNewList,
onAddToList,
onDeleteSelected,
onRemoveFromEvent,
onClearSelection,
}: {
selectedCount: number;
busy?: boolean;
variant: ParticipantBulkVariant;
onAddTags: () => void;
onRemoveTags: () => void;
onAssignEvents?: () => void;
onNewList: () => void;
onAddToList: () => void;
onDeleteSelected?: () => void;
onRemoveFromEvent?: () => void;
onClearSelection: () => void;
}) {
if (selectedCount <= 0) return null;
const btnStyle: React.CSSProperties = {
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: busy ? "wait" : "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
};
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "8px",
padding: "12px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--secondary)",
}}
>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
{selectedCount} selected
</span>
<button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button>
<button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button>
{variant === "portfolio" && onAssignEvents ? (
<button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button>
) : null}
<button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button>
<button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button>
{variant === "portfolio" && onDeleteSelected ? (
<button
type="button"
disabled={busy}
onClick={onDeleteSelected}
style={{ ...btnStyle, border: "1px solid var(--destructive)", background: "transparent", color: "var(--destructive)" }}
>
Delete selected
</button>
) : null}
{variant === "event" && onRemoveFromEvent ? (
<button
type="button"
disabled={busy}
onClick={onRemoveFromEvent}
style={{ ...btnStyle, border: "1px solid var(--destructive)", background: "transparent", color: "var(--destructive)" }}
>
Remove from event
</button>
) : null}
<button
type="button"
onClick={onClearSelection}
style={{ marginLeft: "auto", padding: "6px 12px", borderRadius: "var(--radius-button)", border: "none", background: "transparent", color: "var(--muted)", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}
>
Clear selection
</button>
</div>
);
}

View File

@ -0,0 +1,142 @@
import React from "react";
import type { Event } from "../../../services/api/types";
import type { ParticipantListRecord } from "../../../services/api/participantLists";
export type BulkDialogKind = null | "addTags" | "removeTags" | "assignEvents" | "newList" | "addToList";
export function ParticipantBulkDialogs({
bulkDialog,
bulkBusy,
selectedCount,
tagBulkInput,
onTagBulkInputChange,
newListName,
onNewListNameChange,
newListDesc,
onNewListDescChange,
eventsPick,
eventPickIds,
onToggleEventPick,
fixedLists,
addToListId,
onAddToListIdChange,
onClose,
onApplyTags,
onAssignEvents,
onCreateList,
onAddToList,
}: {
bulkDialog: BulkDialogKind;
bulkBusy: boolean;
selectedCount: number;
tagBulkInput: string;
onTagBulkInputChange: (v: string) => void;
newListName: string;
onNewListNameChange: (v: string) => void;
newListDesc: string;
onNewListDescChange: (v: string) => void;
eventsPick: Event[];
eventPickIds: Set<string>;
onToggleEventPick: (id: string) => void;
fixedLists: ParticipantListRecord[];
addToListId: string;
onAddToListIdChange: (id: string) => void;
onClose: () => void;
onApplyTags: (action: "add_tags" | "remove_tags") => void;
onAssignEvents: () => void;
onCreateList: () => void;
onAddToList: () => void;
}) {
const overlay = (children: React.ReactNode) => (
<div
role="dialog"
aria-modal="true"
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)", zIndex: 205, display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={() => !bulkBusy && onClose()}
>
<div
style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px", width: "min(440px, 92vw)", maxHeight: "80vh", overflow: "auto", boxShadow: "var(--shadow-xl)" }}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
const footer = (primary: React.ReactNode) => (
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px", marginTop: "14px" }}>
<button type="button" disabled={bulkBusy} onClick={onClose} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Cancel</button>
{primary}
</div>
);
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
return overlay(
<>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Apply to {selectedCount} selected (comma-separated).</p>
<input value={tagBulkInput} onChange={(e) => onTagBulkInputChange(e.target.value)} placeholder="e.g. VIP, Sponsor" style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", fontSize: "var(--text-sm)", boxSizing: "border-box" }} />
{footer(
<button type="button" disabled={bulkBusy} onClick={() => onApplyTags(bulkDialog === "addTags" ? "add_tags" : "remove_tags")} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Apply</button>
)}
</>
);
}
if (bulkDialog === "assignEvents") {
return overlay(
<>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>Assign to event(s)</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{selectedCount} people · adds pending membership where missing.</p>
<div style={{ maxHeight: "240px", overflowY: "auto", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", padding: "8px" }}>
{eventsPick.map((ev) => (
<label key={ev._id} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 4px", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<input type="checkbox" checked={eventPickIds.has(ev._id)} onChange={() => onToggleEventPick(ev._id)} />
{ev.name}
</label>
))}
</div>
{footer(
<button type="button" disabled={bulkBusy || eventPickIds.size === 0} onClick={onAssignEvents} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Assign</button>
)}
</>
);
}
if (bulkDialog === "newList") {
return overlay(
<>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>New custom list</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Fixed list with {selectedCount} selected people.</p>
<input value={newListName} onChange={(e) => onNewListNameChange(e.target.value)} placeholder="List name" style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", marginBottom: "8px", boxSizing: "border-box", fontSize: "var(--text-sm)" }} />
<textarea value={newListDesc} onChange={(e) => onNewListDescChange(e.target.value)} placeholder="Description (optional)" rows={3} style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", boxSizing: "border-box", fontSize: "var(--text-sm)", resize: "vertical" }} />
{footer(
<button type="button" disabled={bulkBusy || !newListName.trim()} onClick={onCreateList} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Create</button>
)}
</>
);
}
if (bulkDialog === "addToList") {
return overlay(
<>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>Add to existing list</h3>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Append {selectedCount} people to a fixed list.</p>
{fixedLists.length === 0 ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>No fixed lists yet. Create one first.</p>
) : (
<select value={addToListId} onChange={(e) => onAddToListIdChange(e.target.value)} style={{ width: "100%", padding: "8px 12px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", fontSize: "var(--text-sm)" }}>
{fixedLists.map((L) => (
<option key={L._id} value={L._id}>{L.name}</option>
))}
</select>
)}
{footer(
<button type="button" disabled={bulkBusy || fixedLists.length === 0 || !addToListId} onClick={onAddToList} style={{ padding: "7px 14px", borderRadius: "var(--radius-button)", border: "none", background: "var(--primary)", color: "var(--primary-foreground)", cursor: "pointer", fontSize: "var(--text-sm)" }}>Add</button>
)}
</>
);
}
return null;
}

View File

@ -0,0 +1,56 @@
import React from "react";
import { Plus } from "lucide-react";
export function ParticipantListHeaderActions({
onOpenLists,
onCreateList,
isMobile = false,
}: {
onOpenLists: () => void;
onCreateList: () => void;
isMobile?: boolean;
}) {
return (
<>
<button
type="button"
onClick={onOpenLists}
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={onCreateList}
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} /> {isMobile ? "New list" : "Create Custom List"}
</button>
</>
);
}

View File

@ -0,0 +1,42 @@
import React from "react";
export function ParticipantSelectAllMatching({
total,
loading,
busy,
checked,
onChange,
}: {
total: number;
loading?: boolean;
busy?: boolean;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
if (loading || total <= 0) return null;
return (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: "8px",
flexShrink: 0,
cursor: busy ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
userSelect: "none",
}}
>
<input
type="checkbox"
checked={checked}
disabled={busy}
onChange={(e) => onChange(e.target.checked)}
style={{ width: "16px", height: "16px", cursor: busy ? "wait" : "pointer", accentColor: "var(--primary)" }}
/>
<span>Select all matching filters ({total})</span>
</label>
);
}

View File

@ -0,0 +1,43 @@
import React from "react";
import { tagChipStyle } from "./participantTagUtils";
export function ParticipantTagFilters({
tagOptions,
activeTag,
onChange,
}: {
tagOptions: string[];
activeTag: string | null;
onChange: (tag: string | null) => void;
}) {
if (tagOptions.length === 0) return null;
return (
<div style={{ display: "flex", gap: "5px", flexWrap: "wrap", alignItems: "center" }}>
{tagOptions.map((tag) => {
const active = activeTag === tag;
const tc = tagChipStyle(tag);
return (
<button
key={tag}
type="button"
onClick={() => onChange(active ? null : tag)}
style={{
padding: "3px 10px",
borderRadius: "999px",
border: `1px solid ${active ? tc.color : "var(--border)"}`,
background: active ? tc.bg : "var(--card)",
color: active ? tc.color : "var(--muted)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "600",
cursor: "pointer",
}}
>
{tag}
</button>
);
})}
</div>
);
}

View File

@ -10,8 +10,18 @@ import {
} from "lucide-react";
import { participantsApi } from "../../../services/api";
import type { Participant as ApiParticipant } from "../../../services/api/types";
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
import { AddParticipantPanel } from "../panels/AddParticipantPanel";
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
import { ParticipantQrModal } from "./ParticipantQrModal";
import { tagChipStyle } from "./participantTagUtils";
import { useParticipantSelection } from "./useParticipantSelection";
import { useParticipantBulkActions } from "./useParticipantBulkActions";
import { ParticipantListHeaderActions } from "./ParticipantListHeaderActions";
import { ParticipantTagFilters } from "./ParticipantTagFilters";
import { ParticipantSelectAllMatching } from "./ParticipantSelectAllMatching";
import { ParticipantBulkActionBar } from "./ParticipantBulkActionBar";
import { ParticipantBulkDialogs } from "./ParticipantBulkDialogs";
/* ─────────────── Types ─────────────── */
@ -26,6 +36,7 @@ interface ParticipantDisplay {
status: string;
checkedIn: boolean;
registrationDate: string | null;
tags: string[];
}
function toDisplay(p: ApiParticipant): ParticipantDisplay {
@ -41,6 +52,7 @@ function toDisplay(p: ApiParticipant): ParticipantDisplay {
status: statusLabel,
checkedIn: p.status === "checked_in" || !!p.checkedIn,
registrationDate: regDate,
tags: p.tags ?? [],
};
}
@ -175,9 +187,12 @@ interface RowProps {
onToggleCheckIn: () => void;
onQr: () => void;
canEdit?: boolean;
selectable?: boolean;
selected?: boolean;
onToggleSelect?: () => void;
}
function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit }: RowProps) {
function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) {
const [hovered, setHovered] = useState(false);
return (
@ -186,6 +201,11 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
onMouseLeave={() => setHovered(false)}
style={{ background: hovered ? "var(--secondary)" : "transparent", transition: "background 0.12s", cursor: "default", borderBottom: "1px solid var(--border)" }}
>
{selectable ? (
<td style={{ padding: "12px 8px 12px 16px", width: "36px" }}>
<input type="checkbox" checked={!!selected} onChange={onToggleSelect} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
</td>
) : null}
{/* Participant */}
<td style={{ padding: "12px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
@ -202,6 +222,19 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
<RoleBadge role={p.role} />
</td>
{/* Tags */}
{selectable ? (
<td style={{ padding: "12px 12px" }}>
<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> : null}
</div>
</td>
) : null}
{/* Phone */}
<td style={{ padding: "12px 12px" }}>
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{p.phone}</span>
@ -303,7 +336,7 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
/* ─────────────── Participant Card ─────────────── */
function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit }: RowProps) {
function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) {
const [hovered, setHovered] = useState(false);
return (
@ -320,6 +353,12 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
cursor: "default",
}}
>
{selectable ? (
<label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", cursor: "pointer" }}>
<input type="checkbox" checked={!!selected} onChange={onToggleSelect} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
Select
</label>
) : null}
{/* Top: avatar + name + role */}
<div style={{ display: "flex", alignItems: "flex-start", gap: "12px" }}>
<Avatar name={p.name} size={42} />
@ -512,11 +551,13 @@ export type ParticipantsPageProps = {
};
export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) {
const { selectedEventId } = useAppNav();
const { selectedEventId, setActiveNav } = useAppNav();
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isSuperAdmin = user?.role === "super_admin";
const enableBulk = !compactChrome;
const [list, setList] = useState<ParticipantDisplay[]>([]);
const [total, setTotal] = useState(0);
@ -526,10 +567,40 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("All");
const [designationFilter, setDesignationFilter] = useState("All");
const [activeTag, setActiveTag] = useState<string | null>(null);
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [listNonce, setListNonce] = useState(0);
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>(isMobile ? "card" : "list");
const [searchFocused, setSearchFocused] = useState(false);
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [showCustomList, setShowCustomList] = useState(false);
const [pageMsg, setPageMsg] = useState<string | null>(null);
const {
selectedIds,
setSelectedIds,
selectAllMatchingActive,
setSelectAllMatchingActive,
headerSelectRef,
toggleRow,
toggleAllVisible,
clearSelection,
} = useParticipantSelection(
enableBulk ? list.map((p) => p.id) : [],
enableBulk ? [search, activeTag, statusFilter, designationFilter] : []
);
const refreshList = useCallback(() => setListNonce((n) => n + 1), []);
const bulk = useParticipantBulkActions({
selectedIds: enableBulk ? selectedIds : [],
listOrgId,
eventId: selectedEventId ?? undefined,
onMessage: (msg) => setPageMsg(msg),
onClearSelection: clearSelection,
onRefresh: refreshList,
});
const [viewModal, setViewModal] = useState<ParticipantDisplay | null>(null);
const [qrParticipant, setQrParticipant] = useState<ParticipantDisplay | null>(null);
@ -556,6 +627,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
q: search || undefined,
status: statusFilter !== "All" ? STATUS_API_MAP[statusFilter] ?? statusFilter : undefined,
designation: designationFilter !== "All" ? designationFilter : undefined,
tag: activeTag ?? undefined,
}).then((res) => {
setLoading(false);
if (res.data?.participants) {
@ -563,17 +635,46 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
setTotal(res.data.total ?? 0);
}
}).catch(() => setLoading(false));
}, [selectedEventId, page, limit, search, statusFilter, designationFilter]);
}, [selectedEventId, page, limit, search, statusFilter, designationFilter, activeTag, listNonce]);
useEffect(() => { fetchList(); }, [fetchList]);
useEffect(() => {
if (!selectedEventId || !enableBulk) {
setTagOptions([]);
return;
}
participantsApi.listDistinctTagsForEvent(selectedEventId).then((r) => {
if (r.data?.tags) setTagOptions(r.data.tags);
});
}, [selectedEventId, listNonce, enableBulk]);
const searchDebounce = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
searchDebounce.current = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchInput]);
useEffect(() => { setPage(1); }, [search, statusFilter, designationFilter, limit]);
useEffect(() => { setPage(1); }, [search, statusFilter, designationFilter, activeTag, limit]);
const selectAllMatchingSearch = async () => {
if (!selectedEventId) return;
const r = await participantsApi.matchingIdsForEvent(selectedEventId, {
q: search || undefined,
tag: activeTag ?? undefined,
status: statusFilter !== "All" ? STATUS_API_MAP[statusFilter] ?? statusFilter : undefined,
designation: designationFilter !== "All" ? designationFilter : undefined,
});
if (r.error) {
setPageMsg(r.error);
setSelectAllMatchingActive(false);
return;
}
const ids = r.data?.ids ?? [];
if (r.data?.capped) setPageMsg(`Note: only the first ${ids.length} matching people are selected (limit). Refine filters if needed.`);
setSelectedIds(ids);
setSelectAllMatchingActive(ids.length > 0);
};
const totalPages = Math.max(1, Math.ceil(total / limit));
const checkedIn = list.filter(p => p.checkedIn).length;
@ -624,7 +725,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
e.target.value = "";
};
const filtersActive = statusFilter !== "All" || designationFilter !== "All";
const filtersActive = statusFilter !== "All" || designationFilter !== "All" || !!activeTag;
if (!selectedEventId) {
return (
@ -637,6 +738,12 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
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" }}><X size={16} /></button>
</div>
)}
{exportError && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 14px", borderRadius: "var(--radius-button)", background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<span>{exportError}</span>
@ -693,6 +800,13 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
<Plus size={14} /> {isMobile ? "Add" : "Add Participant"}
</button>
)}
{enableBulk ? (
<ParticipantListHeaderActions
isMobile={isMobile}
onOpenLists={() => setActiveNav("participant-lists")}
onCreateList={() => setShowCustomList(true)}
/>
) : null}
</div>
</div>
@ -758,10 +872,23 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
{/* Filter selects — always visible on desktop, collapsible on mobile */}
{(!isMobile || filtersExpanded) && (
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
{enableBulk ? (
<ParticipantSelectAllMatching
total={total}
loading={loading}
busy={bulk.bulkBusy}
checked={selectAllMatchingActive}
onChange={(checked) => {
if (checked) void selectAllMatchingSearch();
else clearSelection();
}}
/>
) : null}
<FilterSelect label="All Statuses" value={statusFilter} options={STATUS_OPTIONS} onChange={setStatusFilter} />
<FilterSelect label="All Designations" value={designationFilter} options={DESIGNATION_OPTIONS} onChange={setDesignationFilter} />
{enableBulk ? <ParticipantTagFilters tagOptions={tagOptions} activeTag={activeTag} onChange={setActiveTag} /> : null}
{filtersActive && (
<button type="button" onClick={() => { setStatusFilter("All"); setDesignationFilter("All"); }}
<button type="button" onClick={() => { setStatusFilter("All"); setDesignationFilter("All"); setActiveTag(null); }}
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "6px 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
<X size={11} /> Clear
</button>
@ -770,6 +897,22 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
)}
</div>
{enableBulk ? (
<ParticipantBulkActionBar
selectedCount={selectedIds.length}
busy={bulk.bulkBusy}
variant="event"
onAddTags={() => void bulk.openBulkDialog("addTags")}
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
onNewList={() => void bulk.openBulkDialog("newList")}
onAddToList={() => void bulk.openBulkDialog("addToList")}
onRemoveFromEvent={() => {
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
}}
onClearSelection={clearSelection}
/>
) : null}
{/* ── Result summary ── */}
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Showing <strong style={{ color: "var(--foreground)" }}>{(page - 1) * limit + 1}</strong>–<strong>{Math.min(page * limit, total)}</strong> of {total} participants
@ -783,7 +926,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
<Users size={32} style={{ color: "var(--border)", margin: "0 auto 12px" }} />
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "6px" }}>No participants found</p>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: "16px" }}>Try adjusting your search or filters.</p>
<button type="button" onClick={() => { setSearchInput(""); setSearch(""); setStatusFilter("All"); setDesignationFilter("All"); setPage(1); }}
<button type="button" onClick={() => { setSearchInput(""); setSearch(""); setStatusFilter("All"); setDesignationFilter("All"); setActiveTag(null); setPage(1); }}
style={{ display: "inline-flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", color: "var(--card-foreground)", border: "1px solid var(--border)", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)" }}>
<X size={13} /> Clear all filters
</button>
@ -794,7 +937,15 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ background: "var(--secondary)", borderBottom: "1px solid var(--border)" }}>
{["Participant", "Role", "Phone", "Registered", "Status", "Checked In", ""].map(col => (
{enableBulk ? (
<th style={{ padding: "9px 8px 9px 16px", width: "36px" }}>
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
</th>
) : null}
{(enableBulk
? ["Participant", "Role", "Tags", "Phone", "Registered", "Status", "Checked In", ""]
: ["Participant", "Role", "Phone", "Registered", "Status", "Checked In", ""]
).map(col => (
<th key={col} style={{ padding: "9px 12px", textAlign: "left", fontSize: "11px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", letterSpacing: "0.04em", textTransform: "uppercase", whiteSpace: "nowrap" }}>
{col}
</th>
@ -806,6 +957,9 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
<ParticipantRow
key={p.id}
p={p}
selectable={enableBulk}
selected={selectedIds.includes(p.id)}
onToggleSelect={() => toggleRow(p.id)}
onView={() => setViewModal(p)}
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
onDelete={() => deleteParticipant(p.id)}
@ -841,6 +995,9 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
<ParticipantCard
key={p.id}
p={p}
selectable={enableBulk}
selected={selectedIds.includes(p.id)}
onToggleSelect={() => toggleRow(p.id)}
onView={() => setViewModal(p)}
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
onDelete={() => deleteParticipant(p.id)}
@ -930,6 +1087,38 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
</div>
</div>
)}
{enableBulk ? (
<>
<ParticipantBulkDialogs
bulkDialog={bulk.bulkDialog}
bulkBusy={bulk.bulkBusy}
selectedCount={selectedIds.length}
tagBulkInput={bulk.tagBulkInput}
onTagBulkInputChange={bulk.setTagBulkInput}
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) => void bulk.runBulkTags(action)}
onAssignEvents={() => void bulk.runBulkAssignEvents()}
onCreateList={() => void bulk.runBulkNewList()}
onAddToList={() => void bulk.runBulkAddToList()}
/>
{showCustomList ? (
<CreateCustomListPanel
onClose={() => setShowCustomList(false)}
onCreated={() => refreshList()}
/>
) : null}
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,20 @@
export function tagChipStyle(tag: string): { bg: string; color: string } {
const defaults: Record<string, { bg: string; color: string }> = {
VIP: { bg: "#fef3c7", color: "#92400e" },
Speaker: { bg: "var(--accent)", color: "var(--accent-foreground)" },
Sponsor: { bg: "#dcfce7", color: "#15803d" },
Press: { bg: "#f3e8ff", color: "#6b21a8" },
Virtual: { bg: "#e0f2fe", color: "#075985" },
Workshop: { bg: "#ffedd5", color: "#c2410c" },
};
const d = defaults[tag];
if (d) return d;
let h = 0;
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h);
const hue = Math.abs(h) % 360;
return { bg: `hsl(${hue}, 42%, 93%)`, color: `hsl(${hue}, 55%, 30%)` };
}
export function parseTagsBulk(s: string): string[] {
return s.split(/[,;\n]+/).map((t) => t.trim()).filter(Boolean);
}

View File

@ -0,0 +1,181 @@
import { useState } from "react";
import { participantsApi } from "../../../services/api/participants";
import { participantListsApi } from "../../../services/api/participantLists";
import { eventsApi } from "../../../services/api/events";
import type { Event } from "../../../services/api/types";
import type { ParticipantListRecord } from "../../../services/api/participantLists";
import { parseTagsBulk } from "./participantTagUtils";
import type { BulkDialogKind } from "./ParticipantBulkDialogs";
export function useParticipantBulkActions(options: {
selectedIds: string[];
listOrgId?: string;
eventId?: string;
onMessage: (msg: string) => void;
onClearSelection: () => void;
onRefresh: () => void;
}) {
const { selectedIds, listOrgId, eventId, onMessage, onClearSelection, onRefresh } = options;
const [bulkDialog, setBulkDialog] = useState<BulkDialogKind>(null);
const [bulkBusy, setBulkBusy] = useState(false);
const [tagBulkInput, setTagBulkInput] = useState("");
const [newListName, setNewListName] = useState("");
const [newListDesc, setNewListDesc] = useState("");
const [eventsPick, setEventsPick] = useState<Event[]>([]);
const [eventPickIds, setEventPickIds] = useState<Set<string>>(() => new Set());
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [addToListId, setAddToListId] = useState("");
const openBulkDialog = async (kind: NonNullable<BulkDialogKind>) => {
setTagBulkInput("");
setNewListName("");
setNewListDesc("");
setAddToListId("");
setEventPickIds(new Set());
if (kind === "assignEvents") {
if (!listOrgId) {
onMessage("Organisation required to load events.");
return;
}
const er = await eventsApi.list({ limit: 400, organization: listOrgId });
if (er.data?.events) setEventsPick(er.data.events);
}
if (kind === "addToList") {
if (!listOrgId) {
onMessage("Organisation required to load lists.");
return;
}
const lr = await participantListsApi.list({ organization: listOrgId });
const fixed = (lr.data?.lists ?? []).filter((L) => L.type === "fixed");
setFixedLists(fixed);
if (fixed[0]) setAddToListId(fixed[0]._id);
}
setBulkDialog(kind);
};
const toggleEventPick = (eid: string) => {
setEventPickIds((prev) => {
const next = new Set(prev);
if (next.has(eid)) next.delete(eid);
else next.add(eid);
return next;
});
};
const runBulkTags = async (action: "add_tags" | "remove_tags") => {
const tags = parseTagsBulk(tagBulkInput);
if (!tags.length) return;
setBulkBusy(true);
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags });
setBulkBusy(false);
if (res.error) onMessage(res.error);
else {
onMessage(`${action === "add_tags" ? "Added" : "Removed"} tags for ${res.data?.updated ?? 0} participant(s).`);
setBulkDialog(null);
onClearSelection();
onRefresh();
}
};
const runBulkAssignEvents = async () => {
const eventIds = [...eventPickIds];
if (!eventIds.length) return;
setBulkBusy(true);
const res = await participantsApi.bulk({ action: "assign_events", participantIds: selectedIds, eventIds });
setBulkBusy(false);
if (res.error) onMessage(res.error);
else {
onMessage(`Assigned ${selectedIds.length} people to event(s). ${res.data?.membershipsAdded ?? 0} new membership(s).`);
setBulkDialog(null);
onClearSelection();
onRefresh();
}
};
const runBulkNewList = async () => {
const name = newListName.trim();
if (!name) return;
setBulkBusy(true);
const res = await participantListsApi.create({
name,
description: newListDesc.trim(),
type: "fixed",
visibility: "private",
autoUpdate: false,
participantIds: selectedIds,
});
setBulkBusy(false);
if (res.error) onMessage(res.error);
else {
onMessage(`Custom list "${name}" created with ${selectedIds.length} member(s).`);
setBulkDialog(null);
onClearSelection();
onRefresh();
}
};
const runBulkAddToList = async () => {
if (!addToListId) return;
setBulkBusy(true);
const res = await participantsApi.bulk({ action: "add_to_fixed_list", participantIds: selectedIds, listId: addToListId });
setBulkBusy(false);
if (res.error) onMessage(res.error);
else {
onMessage(`Added ${res.data?.updated ?? 0} people to the list.`);
setBulkDialog(null);
onClearSelection();
onRefresh();
}
};
const runBulkDelete = async () => {
setBulkBusy(true);
const res = await participantsApi.bulk({ action: "delete", participantIds: selectedIds });
setBulkBusy(false);
if (res.error) onMessage(res.error);
else {
onMessage(`Deleted ${res.data?.deleted ?? 0} participant(s).`);
onClearSelection();
onRefresh();
}
};
const runBulkRemoveFromEvent = async () => {
if (!eventId) return;
setBulkBusy(true);
const res = await participantsApi.bulk({ action: "remove_from_event", participantIds: selectedIds, eventId });
setBulkBusy(false);
if (res.error) onMessage(res.error);
else {
onMessage(`Removed ${res.data?.removed ?? 0} participant(s) from this event.`);
onClearSelection();
onRefresh();
}
};
return {
bulkDialog,
setBulkDialog,
bulkBusy,
tagBulkInput,
setTagBulkInput,
newListName,
setNewListName,
newListDesc,
setNewListDesc,
eventsPick,
eventPickIds,
toggleEventPick,
fixedLists,
addToListId,
setAddToListId,
openBulkDialog,
runBulkTags,
runBulkAssignEvents,
runBulkNewList,
runBulkAddToList,
runBulkDelete,
runBulkRemoveFromEvent,
};
}

View File

@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from "react";
export function useParticipantSelection(visibleIds: string[], filterDeps: unknown[] = []) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [selectAllMatchingActive, setSelectAllMatchingActive] = useState(false);
const headerSelectRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setSelectAllMatchingActive(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, filterDeps);
useEffect(() => {
if (selectedIds.length === 0) setSelectAllMatchingActive(false);
}, [selectedIds.length]);
useEffect(() => {
const el = headerSelectRef.current;
if (!el || visibleIds.length === 0) return;
const n = visibleIds.filter((id) => selectedIds.includes(id)).length;
el.indeterminate = n > 0 && n < visibleIds.length;
el.checked = n === visibleIds.length && visibleIds.length > 0;
}, [visibleIds, selectedIds]);
const toggleRow = (id: string) => {
setSelectAllMatchingActive(false);
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleAllVisible = () => {
setSelectAllMatchingActive(false);
setSelectedIds((prev) => {
const set = new Set(prev);
const allOn = visibleIds.length > 0 && visibleIds.every((id) => set.has(id));
if (allOn) visibleIds.forEach((id) => set.delete(id));
else visibleIds.forEach((id) => set.add(id));
return [...set];
});
};
const clearSelection = () => {
setSelectAllMatchingActive(false);
setSelectedIds([]);
};
return {
selectedIds,
setSelectedIds,
selectAllMatchingActive,
setSelectAllMatchingActive,
headerSelectRef,
toggleRow,
toggleAllVisible,
clearSelection,
};
}

View File

@ -472,7 +472,7 @@ function EditAccessModal({
}
export function PermissionsPage() {
const { selectedEventId } = useAppNav();
const { selectedEventId, permissionsOpenAddOnce, setPermissionsOpenAddOnce } = useAppNav();
const [eventName, setEventName] = useState<string | null>(null);
const [members, setMembers] = useState<EventStaffMember[]>([]);
const [invites, setInvites] = useState<PendingInvite[]>([]);
@ -515,6 +515,12 @@ export function PermissionsPage() {
loadData();
}, [selectedEventId]);
useEffect(() => {
if (!permissionsOpenAddOnce) return;
setShowAdd(true);
setPermissionsOpenAddOnce(false);
}, [permissionsOpenAddOnce, setPermissionsOpenAddOnce]);
const filteredMembers = useMemo(() => {
if (!search.trim()) return members;
const q = search.toLowerCase();

View File

@ -23,6 +23,8 @@ export interface Question {
imageUrl?: string;
/** When true, show per-option image uploads in the editor */
useImagesOptions?: boolean;
/** Seconds for this question (quiz master + time restricted) */
timeLimit?: number;
}
interface Props {
@ -31,6 +33,8 @@ interface Props {
initial?: Partial<Question>;
/** Passed to media upload (S3 path); optional — backend falls back to a default segment */
eventId?: string;
/** Show per-question time limit field (quiz master + time restricted) */
showPerQuestionTimeLimit?: boolean;
}
/* ─── Shared input style helper ─── */
@ -67,12 +71,15 @@ function optionHasContent(o: QuestionOption) {
return Boolean(o.text.trim() || o.imageUrl);
}
export function AddQuestionModal({ onSave, onClose, initial, eventId }: Props) {
export function AddQuestionModal({ onSave, onClose, initial, eventId, showPerQuestionTimeLimit }: Props) {
const [text, setText] = useState(initial?.text ?? "");
const [type, setType] = useState<QuestionType>(initial?.type ?? "Single Choice");
const [order, setOrder] = useState(initial?.order ?? 0);
const [status, setStatus] = useState<"Active" | "Draft">(initial?.status ?? "Active");
const [questionImageUrl, setQuestionImageUrl] = useState(initial?.imageUrl ?? "");
const [timeLimit, setTimeLimit] = useState(
typeof initial?.timeLimit === "number" && Number.isFinite(initial.timeLimit) ? initial.timeLimit : 0,
);
const [options, setOptions] = useState<QuestionOption[]>(
initial?.options ?? [
{ id: "o1", text: "", isCorrect: false },
@ -146,6 +153,10 @@ export function AddQuestionModal({ onSave, onClose, initial, eventId }: Props) {
if (!text.trim()) e.text = "Question text is required.";
const filled = options.filter(optionHasContent);
if (filled.length < 2) e.options = "At least 2 options with text and/or image are required.";
if (showPerQuestionTimeLimit) {
const sec = Math.floor(Number(timeLimit));
if (!Number.isFinite(sec) || sec <= 0) e.timeLimit = "Time limit must be greater than 0 seconds.";
}
setErrors(e);
return Object.keys(e).length === 0;
};
@ -161,6 +172,9 @@ export function AddQuestionModal({ onSave, onClose, initial, eventId }: Props) {
options,
imageUrl: questionImageUrl.trim() || undefined,
useImagesOptions: useImages,
...(showPerQuestionTimeLimit
? { timeLimit: Math.max(0, Math.floor(Number(timeLimit)) || 0) }
: {}),
});
onClose();
};
@ -430,6 +444,57 @@ export function AddQuestionModal({ onSave, onClose, initial, eventId }: Props) {
</div>
</div>
{showPerQuestionTimeLimit ? (
<div>
<label style={labelStyle}>
Time Limit (seconds)
{requiredDot}
</label>
<input
type="number"
min={1}
value={timeLimit}
onChange={(e) => {
setTimeLimit(Number(e.target.value));
if (errors.timeLimit) setErrors((err) => ({ ...err, timeLimit: "" }));
}}
style={{
...inputStyle,
borderColor: errors.timeLimit ? "var(--destructive)" : "var(--border)",
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = "var(--ring)";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = errors.timeLimit ? "var(--destructive)" : "var(--border)";
}}
/>
{errors.timeLimit ? (
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--destructive)",
marginTop: "4px",
}}
>
{errors.timeLimit}
</p>
) : (
<p
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
marginTop: "4px",
}}
>
How long attendees have to answer this question in Quiz Master mode.
</p>
)}
</div>
) : null}
{/* Options */}
<div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "8px" }}>

View File

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { X, Plus, Pencil, Trash2, HelpCircle } from "lucide-react";
import { AddQuestionModal, Question } from "./AddQuestionModal";
import { isPerQuestionTimeLimitMode } from "./quizQuestionMappers";
import type { QuizPoll, QuizPollDisplayMode, QuizPollSettings } from "../../../services/api/quizPolls";
import { FlatpickrDatetimeInput } from "../ui/FlatpickrDatetimeInput";
@ -243,7 +244,19 @@ function SelectInput({ value, onChange, options, error }: { value: string; onCha
);
}
function QuestionRow({ q, index, onEdit, onDelete }: { q: Question; index: number; onEdit: () => void; onDelete: () => void }) {
function QuestionRow({
q,
index,
onEdit,
onDelete,
showPerQuestionTimeLimit,
}: {
q: Question;
index: number;
onEdit: () => void;
onDelete: () => void;
showPerQuestionTimeLimit?: boolean;
}) {
const [hovered, setHovered] = useState(false);
return (
<div
@ -276,6 +289,9 @@ function QuestionRow({ q, index, onEdit, onDelete }: { q: Question; index: numbe
{q.options.some((o) => o.imageUrl) && (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>with images</span>
)}
{showPerQuestionTimeLimit && typeof q.timeLimit === "number" && q.timeLimit > 0 ? (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{q.timeLimit}s limit</span>
) : null}
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "#15803d" }}>{q.options.filter((o) => o.isCorrect).length} correct</span>
</div>
</div>
@ -340,6 +356,7 @@ export function AddQuizPollModal({
const [form, setForm] = useState<QuizPollModalForm>(initial?.form ?? defaultQuizPollModalForm());
const label = kind === "poll" ? "Poll" : "Quiz";
const perQuestionTimeLimit = isPerQuestionTimeLimitMode(form);
const set = (k: keyof QuizPollModalForm, v: string | number) => {
setForm((f) => ({ ...f, [k]: v }));
@ -367,6 +384,16 @@ export function AddQuizPollModal({
setErrors((er) => ({ ...er, questions: "Add at least one question." }));
return;
}
if (perQuestionTimeLimit) {
const missing = questions.some((q) => !q.timeLimit || q.timeLimit <= 0);
if (missing) {
setErrors((er) => ({
...er,
questions: "Each question needs a time limit (seconds) in Quiz Master mode with Time Restricted enabled.",
}));
return;
}
}
void onSave?.({ form, questions });
};
@ -512,25 +539,35 @@ export function AddQuizPollModal({
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
<Field label="Time Restricted" required hint="If enabled, enforce a time limit when the user takes the quiz/poll (synced to the database and live clients).">
<div style={{ display: "grid", gridTemplateColumns: perQuestionTimeLimit ? "1fr" : "1fr 1fr", gap: "16px" }}>
<Field
label="Time Restricted"
required
hint={
perQuestionTimeLimit
? "When enabled, set a time limit on each question in the Questions step (Quiz Master mode)."
: "If enabled, enforce a time limit when the user takes the quiz/poll (synced to the database and live clients)."
}
>
<SelectInput value={form.timeRestricted} onChange={(v) => set("timeRestricted", v)} options={yesNoOptions} />
</Field>
<Field label="Time Limit (seconds)" hint="Only used if Time Restricted is Yes.">
<input
type="number"
min={0}
value={form.timeLimit}
onChange={(e) => set("timeLimit", Number(e.target.value))}
style={inputStyle}
onFocus={(e) => {
e.currentTarget.style.borderColor = "var(--ring)";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
}}
/>
</Field>
{!perQuestionTimeLimit ? (
<Field label="Time Limit (seconds)" hint="Only used if Time Restricted is Yes.">
<input
type="number"
min={0}
value={form.timeLimit}
onChange={(e) => set("timeLimit", Number(e.target.value))}
style={inputStyle}
onFocus={(e) => {
e.currentTarget.style.borderColor = "var(--ring)";
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
}}
/>
</Field>
) : null}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
@ -623,7 +660,14 @@ export function AddQuizPollModal({
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{questions.map((q, i) => (
<QuestionRow key={q.id} q={q} index={i} onEdit={() => setEditingQ(q)} onDelete={() => deleteQuestion(q.id)} />
<QuestionRow
key={q.id}
q={q}
index={i}
onEdit={() => setEditingQ(q)}
onDelete={() => deleteQuestion(q.id)}
showPerQuestionTimeLimit={perQuestionTimeLimit}
/>
))}
<button type="button" onClick={() => setShowAddQ(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "10px", borderRadius: "var(--radius-card)", border: "2px dashed var(--border)", background: "transparent", color: "var(--muted)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer", justifyContent: "center", transition: "border-color 0.12s, color 0.12s" }} 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)"; }}>
<Plus size={13} /> Add another question
@ -670,8 +714,23 @@ export function AddQuizPollModal({
</div>
)}
{showAddQ && <AddQuestionModal eventId={eventId} onClose={() => setShowAddQ(false)} onSave={addQuestion} />}
{editingQ && <AddQuestionModal eventId={eventId} initial={editingQ} onClose={() => setEditingQ(null)} onSave={saveEdit} />}
{showAddQ && (
<AddQuestionModal
eventId={eventId}
showPerQuestionTimeLimit={perQuestionTimeLimit}
onClose={() => setShowAddQ(false)}
onSave={addQuestion}
/>
)}
{editingQ && (
<AddQuestionModal
eventId={eventId}
initial={editingQ}
showPerQuestionTimeLimit={perQuestionTimeLimit}
onClose={() => setEditingQ(null)}
onSave={saveEdit}
/>
)}
</>
);
}

View File

@ -0,0 +1,362 @@
import React, { useState } from "react";
import { X } from "lucide-react";
import type { QuizPoll, QuizPollQuestion } from "../../../services/api/quizPolls";
import { quizPollsApi } from "../../../services/api/quizPolls";
function ShowCorrectButton({
active,
onClick,
busy,
disabled,
}: {
active: boolean;
onClick: () => void;
busy?: boolean;
disabled?: boolean;
}) {
return (
<button
type="button"
disabled={disabled || busy}
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 10px",
borderRadius: "999px",
background: active ? "#fffbeb" : "var(--secondary)",
border: `1px solid ${active ? "#fcd34d" : "var(--border)"}`,
cursor: disabled || busy ? "not-allowed" : "pointer",
transition: "all 0.15s",
whiteSpace: "nowrap",
opacity: disabled ? 0.45 : busy ? 0.7 : 1,
}}
>
<span
style={{
fontSize: "11px",
fontFamily: "var(--font-noto-sans)",
fontWeight: "600",
color: active ? "#b45309" : "var(--muted)",
}}
>
{active ? "Hide Correct" : "Show Correct"}
</span>
</button>
);
}
function LiveActionButton({ live, onClick, busy }: { live: boolean; onClick: () => void; busy?: boolean }) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 10px",
borderRadius: "999px",
background: live ? "#f0fdf4" : "var(--secondary)",
border: `1px solid ${live ? "#bbf7d0" : "var(--border)"}`,
cursor: busy ? "wait" : "pointer",
transition: "all 0.15s",
whiteSpace: "nowrap",
opacity: busy ? 0.7 : 1,
}}
>
<span
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: live ? "#16a34a" : "var(--border)",
display: "block",
flexShrink: 0,
}}
/>
<span
style={{
fontSize: "11px",
fontFamily: "var(--font-noto-sans)",
fontWeight: "600",
color: live ? "#15803d" : "var(--muted)",
}}
>
{live ? "Make Offline" : "Make Live"}
</span>
</button>
);
}
function questionIdOf(q: QuizPollQuestion, index: number): string {
if (q._id != null && String(q._id).trim() !== "") return String(q._id);
return `q-${index}`;
}
export function QuizMasterPresenterPanel({
quiz,
onClose,
onUpdated,
}: {
quiz: QuizPoll;
onClose: () => void;
onUpdated: () => void | Promise<void>;
}) {
const [busyId, setBusyId] = useState<string | null>(null);
const [showCorrectBusy, setShowCorrectBusy] = useState(false);
const [closing, setClosing] = useState(false);
const [err, setErr] = useState<string | null>(null);
const liveQuestionId =
quiz.settings?.liveQuestionId != null && String(quiz.settings.liveQuestionId).trim() !== ""
? String(quiz.settings.liveQuestionId).trim()
: null;
const showCorrectAnswer = quiz.settings?.showCorrectAnswer === true;
const questions = quiz.questions || [];
const setQuestionLive = async (questionId: string | null) => {
setErr(null);
setBusyId(questionId ?? "__clear__");
const res = await quizPollsApi.setLiveQuestion(quiz._id, questionId);
setBusyId(null);
if (res.error) {
setErr(res.error);
return;
}
await onUpdated();
};
const toggleShowCorrect = async () => {
setErr(null);
setShowCorrectBusy(true);
const res = await quizPollsApi.setShowCorrect(quiz._id, !showCorrectAnswer);
setShowCorrectBusy(false);
if (res.error) {
setErr(res.error);
return;
}
await onUpdated();
};
const makeQuizOffline = async () => {
setErr(null);
setClosing(true);
const res = await quizPollsApi.close(quiz._id);
setClosing(false);
if (res.error) {
setErr(res.error);
return;
}
await onUpdated();
onClose();
};
return (
<>
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 200 }} role="presentation" />
<div
style={{
position: "fixed",
top: 0,
right: 0,
height: "100%",
width: "min(720px, 100vw)",
background: "var(--card)",
zIndex: 201,
display: "flex",
flexDirection: "column",
boxShadow: "-4px 0 32px rgba(0,0,0,0.18)",
animation: "quizMasterPanelIn 0.25s ease",
}}
>
<style>{`@keyframes quizMasterPanelIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: "12px",
}}
>
<div>
<h2
style={{
fontFamily: "var(--font-inter)",
fontSize: "var(--text-lg)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
margin: 0,
}}
>
Present quiz
</h2>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "6px 0 0", lineHeight: 1.45 }}>
{quiz.title}
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "4px 0 0", lineHeight: 1.45 }}>
Make one question live at a time. Attendees only see the live question.
</p>
</div>
<button type="button" onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", padding: 4 }}>
<X size={20} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "12px 20px" }}>
{err ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--destructive)", marginBottom: "12px" }}>{err}</p>
) : null}
{questions.length === 0 ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", padding: "24px 0", textAlign: "center" }}>
No questions on this quiz.
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{questions.map((q, i) => {
const qid = questionIdOf(q, i);
const isLive = liveQuestionId === qid;
const busy = busyId === qid || (busyId === "__clear__" && isLive);
return (
<div
key={qid}
style={{
display: "flex",
alignItems: "flex-start",
gap: "12px",
padding: "12px 14px",
border: `1px solid ${isLive ? "#bbf7d0" : "var(--border)"}`,
borderRadius: "var(--radius-card)",
background: isLive ? "#f0fdf4" : "var(--card)",
}}
>
<span
style={{
width: "22px",
height: "22px",
borderRadius: "50%",
background: isLive ? "#16a34a" : "var(--accent)",
color: isLive ? "#fff" : "var(--primary)",
fontSize: "11px",
fontFamily: "var(--font-inter)",
fontWeight: "600",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
marginTop: "1px",
}}
>
{i + 1}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--foreground)",
lineHeight: 1.4,
marginBottom: "6px",
}}
>
{q.question}
</p>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap", alignItems: "center" }}>
<span
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
padding: "1px 7px",
borderRadius: "3px",
background: "var(--accent)",
color: "var(--accent-foreground)",
}}
>
{q.type || "single"}
</span>
{typeof q.timeLimit === "number" && q.timeLimit > 0 ? (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
{q.timeLimit}s limit
</span>
) : null}
{isLive ? (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "#15803d", fontWeight: "600" }}>
Live now
</span>
) : null}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", flexShrink: 0, alignItems: "flex-end" }}>
<LiveActionButton
live={isLive}
busy={busy}
onClick={() => void setQuestionLive(isLive ? null : qid)}
/>
<ShowCorrectButton
active={isLive && showCorrectAnswer}
busy={showCorrectBusy}
disabled={!isLive}
onClick={() => void toggleShowCorrect()}
/>
</div>
</div>
);
})}
</div>
)}
</div>
<div
style={{
padding: "14px 20px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
display: "flex",
justifyContent: "flex-end",
gap: "8px",
}}
>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
}}
>
Close
</button>
<button
type="button"
disabled={closing}
onClick={() => void makeQuizOffline()}
style={{
padding: "8px 16px",
borderRadius: "var(--radius-button)",
border: "none",
background: "var(--destructive)",
color: "#fff",
cursor: closing ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: closing ? 0.8 : 1,
}}
>
{closing ? "Taking offline…" : "Make quiz offline"}
</button>
</div>
</div>
</>
);
}

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo } from "react";
import {
Trophy, Plus, X, BarChart2,
Pencil, Trash2, ChevronDown, Check, Minus, GripVertical,
Copy, BarChart, CheckSquare, Calendar,
Copy, BarChart, CheckSquare, Calendar, Monitor, MessageSquare, ArrowLeft,
} from "lucide-react";
import {
AddQuizPollModal,
@ -11,11 +11,16 @@ import {
type QuizPollModalForm,
} from "./AddQuizPollModal";
import type { Question } from "./AddQuestionModal";
import type { QuizPollQuestion } from "../../../services/api/quizPolls";
import { apiQuestionsToModal, modalQuestionToApi } from "./quizQuestionMappers";
import { QuizMasterPresenterPanel } from "./QuizMasterPresenterPanel";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext";
import { eventStaffApi, type AgendaTrack } from "../../../services/api/eventStaff";
import { quizPollsApi, type QuizPoll } from "../../../services/api/quizPolls";
import { sessionsApi, type ApiSession } from "../../../services/api/sessions";
import { questionsApi } from "../../../services/api/questions";
import { effectiveEnableQnA, sessionToPayload } from "../../utils/sessionFlags";
import { QuestionsPage } from "../questions/QuestionsPage";
import {
buildDateGroups,
buildFlatSessions,
@ -107,11 +112,25 @@ function DuplicateSessionModal({
questions: (src.questions || []).map((q) => ({
question: q.question,
type: q.type,
options: q.options,
imageUrl: q.imageUrl,
options: (q.options || []).map((o) => ({
text: o.text,
isCorrect: o.isCorrect,
imageUrl: o.imageUrl,
})),
points: q.points,
required: q.required,
timeLimit: q.timeLimit,
})),
settings: src.settings ? { ...src.settings, isLive: false } : undefined,
settings: src.settings
? {
...src.settings,
isLive: false,
liveQuestionId: null,
showCorrectAnswer: false,
presentationToken: undefined,
}
: undefined,
};
const createRes = await quizPollsApi.create(eventId, payload);
setCopying(false);
@ -162,7 +181,7 @@ function LiveToggle({ live, onChange }: { live: boolean; onChange: () => void })
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "4px 10px", borderRadius: "999px", background: live ? "#f0fdf4" : "var(--secondary)", border: `1px solid ${live ? "#bbf7d0" : "var(--border)"}`, cursor: "pointer", transition: "all 0.15s", whiteSpace: "nowrap" }}>
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: live ? "#16a34a" : "var(--border)", display: "block", flexShrink: 0 }} />
<span style={{ fontSize: "11px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: live ? "#15803d" : "var(--muted)" }}>
{live ? "Live" : "Not Live"}
{live ? "Make Offline" : "Make Live"}
</span>
</button>
);
@ -342,13 +361,47 @@ function LeaderboardModal({ polls, quizzes, onClose }: { polls: { id: string; na
);
}
function isQuizMasterMode(displayMode?: string | null): boolean {
return (displayMode ?? "").trim() === "quiz_master";
}
function isManualMode(displayMode?: string | null): boolean {
return (displayMode ?? "").trim() === "manual";
}
const actionBtnStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 10px",
borderRadius: "999px",
background: "var(--secondary)",
border: "1px solid var(--border)",
cursor: "pointer",
transition: "all 0.15s",
whiteSpace: "nowrap",
fontSize: "11px",
fontFamily: "var(--font-noto-sans)",
fontWeight: "600",
color: "var(--muted)",
};
/* ─── Item Row (shared for polls & quizzes) ─── */
function ItemRow({
name, description, badge, status, live, onToggleLive,
name, description, badge, status, live, showLiveToggle, showQuizMasterControls,
onToggleLive, onPresent, onMakeOffline,
onCopyPresentation, presentationCopied,
onDuplicate, onEdit, onDelete,
}: {
name: string; description: string; badge?: string; status: ItemStatus;
live: boolean; onToggleLive: () => void;
live: boolean;
showLiveToggle?: boolean;
showQuizMasterControls?: boolean;
onToggleLive: () => void;
onPresent?: () => void;
onMakeOffline?: () => void;
onCopyPresentation?: () => void;
presentationCopied?: boolean;
onDuplicate?: () => void; onEdit?: () => void; onDelete?: () => void;
}) {
const [hovered, setHovered] = useState(false);
@ -369,7 +422,37 @@ function ItemRow({
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", lineHeight: "1.45", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "480px" }}>{description}</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "6px", flexShrink: 0 }}>
<LiveToggle live={live} onChange={onToggleLive} />
{showQuizMasterControls ? (
<>
{live ? (
<button type="button" onClick={onPresent} style={actionBtnStyle}>
Manage
</button>
) : (
<button type="button" onClick={onPresent} style={{ ...actionBtnStyle, color: "var(--primary)", borderColor: "var(--primary)" }}>
Make Live
</button>
)}
{live ? <LiveToggle live onChange={() => onMakeOffline?.()} /> : null}
</>
) : null}
{showLiveToggle ? <LiveToggle live={live} onChange={onToggleLive} /> : null}
<button
type="button"
onClick={onCopyPresentation}
style={iconBtnStyle}
title="Copy presentation link"
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)";
}}
>
{presentationCopied ? <Check size={12} /> : <Monitor size={12} />}
</button>
<button type="button" onClick={onDuplicate} style={iconBtnStyle} title="Duplicate" 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>
<button type="button" onClick={onEdit} style={iconBtnStyle} title="Edit" 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>
<button type="button" onClick={onDelete} style={{ ...iconBtnStyle, color: "var(--muted)" }} title="Delete" 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={12} /></button>
@ -378,7 +461,16 @@ function ItemRow({
);
}
function quizPollToItem(q: QuizPoll): { id: string; name: string; description: string; status: ItemStatus; live: boolean; type?: string } {
function quizPollToItem(q: QuizPoll): {
id: string;
name: string;
description: string;
status: ItemStatus;
live: boolean;
showLiveToggle: boolean;
showQuizMasterControls: boolean;
type?: string;
} {
const st = q.settings?.status;
const legacyDraft =
!st &&
@ -392,73 +484,87 @@ function quizPollToItem(q: QuizPoll): { id: string; name: string; description: s
description: q.description ?? "",
status: (isDraft ? "Draft" : "Active") as ItemStatus,
live: resolvedLive,
showLiveToggle: isManualMode(q.settings?.displayMode),
showQuizMasterControls: isQuizMasterMode(q.settings?.displayMode),
type: q.type === "quiz" ? "Quiz" : undefined,
};
}
function apiQuestionsToModal(qs: QuizPollQuestion[] | undefined): Question[] {
if (!qs?.length) return [];
return qs.map((q, i) => {
const opts = (q.options || []).map((o, j) => ({
id: `o-${i}-${j}`,
text: o.text || "",
isCorrect: !!o.isCorrect,
imageUrl: o.imageUrl,
}));
const useImagesOptions = opts.some((o) => o.imageUrl);
return {
id: `q-${i}`,
text: q.question || "",
imageUrl: q.imageUrl,
useImagesOptions,
type: (q.type === "multiple"
? "Multiple Choice"
: q.type === "text" || q.type === "rating"
? "Single Choice"
: "Single Choice") as Question["type"],
order: i,
options: opts,
status: "Active",
};
});
}
const sectionActionBtn: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "5px",
padding: "5px 12px",
borderRadius: "var(--radius-button)",
border: "none",
cursor: "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
};
/* ─── Section wrapper ─── */
function Section({ icon, iconColor, title, count, addLabel, onAdd, children }: {
icon: React.ReactNode; iconColor: string; title: string; count: number;
addLabel: string; onAdd?: () => void; children: React.ReactNode;
function Section({
icon,
iconColor,
title,
count,
addLabel,
onAdd,
headerActions,
hideAdd,
children,
emptyBody,
}: {
icon: React.ReactNode;
iconColor: string;
title: string;
count: number;
addLabel?: string;
onAdd?: () => void;
headerActions?: React.ReactNode;
hideAdd?: boolean;
children?: React.ReactNode;
emptyBody?: React.ReactNode;
}) {
return (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "13px 18px", borderBottom: "1px solid var(--border)", background: "var(--secondary)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "13px 18px", borderBottom: "1px solid var(--border)", background: "var(--secondary)", flexWrap: "wrap" }}>
<span style={{ color: iconColor }}>{icon}</span>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{title}</span>
<span style={{ width: "18px", height: "18px", borderRadius: "50%", background: iconColor, color: "#fff", fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", display: "flex", alignItems: "center", justifyContent: "center" }}>{count}</span>
<div style={{ flex: 1 }} />
<button onClick={onAdd} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "5px 12px", borderRadius: "var(--radius-button)", background: iconColor, color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={12} /> {addLabel}
</button>
{headerActions ?? (
!hideAdd && onAdd && addLabel ? (
<button type="button" onClick={onAdd} style={{ ...sectionActionBtn, background: iconColor, color: "#fff" }}>
<Plus size={12} /> {addLabel}
</button>
) : null
)}
</div>
{children}
{emptyBody ?? children}
</div>
);
}
type QuizPollEditor = null | { mode: "create"; kind: "poll" | "quiz" } | { mode: "edit"; id: string };
export type QuizPollsPageProps = {
export type EventActivitiesPageProps = {
fixedSessionId?: string | null;
compactChrome?: boolean;
/** When false, hides the Q&A section (e.g. activity detail quiz-polls tab). */
includeQnASection?: boolean;
/** After a quiz/poll is successfully created or updated (e.g. flip session enableQnP on activity). */
onQuizPollPersisted?: () => void | Promise<void>;
};
/* ─── Main Page ─── */
export function QuizPollsPage({
export function EventActivitiesPage({
fixedSessionId = null,
compactChrome = false,
includeQnASection = true,
onQuizPollPersisted,
}: QuizPollsPageProps = {}) {
}: EventActivitiesPageProps = {}) {
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const { selectedEventId } = useAppNav();
@ -471,11 +577,19 @@ export function QuizPollsPage({
questions: Question[];
} | null>(null);
const [duplicateItem, setDuplicateItem] = useState<{ id: string; type: "poll" | "quiz" } | null>(null);
const [presenterQuizId, setPresenterQuizId] = useState<string | null>(null);
const [copiedPresentationId, setCopiedPresentationId] = useState<string | null>(null);
const [presentationMsg, setPresentationMsg] = useState<string | null>(null);
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
const [quizPolls, setQuizPolls] = useState<QuizPoll[]>([]);
const [loadingAgenda, setLoadingAgenda] = useState(false);
const [loadingPolls, setLoadingPolls] = useState(false);
const [apiSession, setApiSession] = useState<ApiSession | null>(null);
const [loadingSession, setLoadingSession] = useState(false);
const [questionCount, setQuestionCount] = useState(0);
const [qnaManageOpen, setQnaManageOpen] = useState(false);
const [qnaToggleBusy, setQnaToggleBusy] = useState(false);
const dateGroups = useMemo(() => buildDateGroups(tracks), [tracks]);
const flatSessions = useMemo(() => buildFlatSessions(tracks), [tracks]);
@ -522,6 +636,49 @@ export function QuizPollsPage({
loadQuizPolls();
}, [sessionId]);
useEffect(() => {
setQnaManageOpen(false);
if (!selectedEventId || !sessionId || !includeQnASection) {
setApiSession(null);
setQuestionCount(0);
return;
}
let cancelled = false;
setLoadingSession(true);
void sessionsApi.list(selectedEventId).then((res) => {
if (cancelled) return;
setLoadingSession(false);
const match = (res.data?.sessions ?? []).find((s) => s._id === sessionId) ?? null;
setApiSession(match);
});
void questionsApi.listBySession(sessionId).then((res) => {
if (cancelled) return;
setQuestionCount(res.data?.questions?.length ?? 0);
});
return () => {
cancelled = true;
};
}, [selectedEventId, sessionId, includeQnASection]);
const enableQnAOn = apiSession ? effectiveEnableQnA(apiSession) : true;
const toggleEnableQnA = async (next: boolean) => {
if (!apiSession || qnaToggleBusy) return;
setQnaToggleBusy(true);
try {
const res = await sessionsApi.update(apiSession._id, {
...sessionToPayload(apiSession),
enableQnA: next,
});
if (!res.error) {
setApiSession(res.data ?? { ...apiSession, enableQnA: next });
if (!next) setQnaManageOpen(false);
}
} finally {
setQnaToggleBusy(false);
}
};
useEffect(() => {
if (!editor || editor.mode !== "edit") {
setEditorLoaded(null);
@ -543,6 +700,10 @@ export function QuizPollsPage({
const togglePollLive = async (id: string) => {
const q = quizPolls.find((x) => x._id === id);
if (!q) return;
if (isQuizMasterMode(q.settings?.displayMode)) {
setPresenterQuizId(id);
return;
}
const next = !(q.settings?.isLive ?? q.settings?.isActive);
await quizPollsApi[next ? "publish" : "close"](id);
if (sessionId) {
@ -553,6 +714,10 @@ export function QuizPollsPage({
const toggleQuizLive = async (id: string) => {
const q = quizPolls.find((x) => x._id === id);
if (!q) return;
if (isQuizMasterMode(q.settings?.displayMode)) {
setPresenterQuizId(id);
return;
}
const next = !(q.settings?.isLive ?? q.settings?.isActive);
await quizPollsApi[next ? "publish" : "close"](id);
if (sessionId) {
@ -561,6 +726,32 @@ export function QuizPollsPage({
}
};
const makeQuizMasterOffline = async (id: string) => {
await quizPollsApi.close(id);
if (presenterQuizId === id) setPresenterQuizId(null);
loadQuizPolls();
};
const copyPresentationLink = async (id: string) => {
setPresentationMsg(null);
const res = await quizPollsApi.ensurePresentationToken(id);
if (res.error || !res.data?.presentationUrl) {
setPresentationMsg(res.error ?? "Could not create presentation link.");
return;
}
try {
await navigator.clipboard.writeText(res.data.presentationUrl);
setCopiedPresentationId(id);
setPresentationMsg("Presentation link copied to clipboard.");
window.setTimeout(() => setCopiedPresentationId(null), 4000);
window.setTimeout(() => setPresentationMsg(null), 4000);
} catch {
setPresentationMsg("Could not copy to clipboard.");
}
};
const presenterQuiz = presenterQuizId ? quizPolls.find((q) => q._id === presenterQuizId) ?? null : null;
const closeQuizPollEditor = () => {
setEditor(null);
setEditorLoaded(null);
@ -570,24 +761,12 @@ export function QuizPollsPage({
if (!selectedEventId || !sessionId || !editor) return;
const kind = (editor.mode === "create" ? editor.kind : editorLoaded?.kind ?? "poll") as "quiz" | "poll";
const editId = editor.mode === "edit" ? editor.id : null;
const mapQuestion = (q: Question) => ({
question: q.text,
imageUrl: q.imageUrl?.trim() ? q.imageUrl.trim() : null,
type: (q.type === "Single Choice" || q.type === "True / False" ? "single" : q.type === "Multiple Choice" ? "multiple" : "text") as "single" | "multiple" | "text" | "rating",
options: q.options?.map((o) => ({
text: o.text,
isCorrect: o.isCorrect,
imageUrl: o.imageUrl?.trim() ? o.imageUrl.trim() : null,
})),
required: true as const,
...(kind === "quiz" ? { points: 1 } : {}),
});
const payload = {
session: sessionId,
title: data.form.title,
type: kind,
description: data.form.description || undefined,
questions: data.questions.map(mapQuestion),
questions: data.questions.map((q) => modalQuestionToApi(q, kind)),
settings: buildQuizPollSettingsPayload(data.form, kind),
};
if (editId) {
@ -623,7 +802,7 @@ export function QuizPollsPage({
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24, alignItems: "center", justifyContent: "center", minHeight: 280 }}>
<Calendar size={40} style={{ color: "var(--border)" }} />
<p style={{ fontSize: "var(--text-base)", color: "var(--muted)" }}>Select an event to manage quizzes and polls.</p>
<p style={{ fontSize: "var(--text-base)", color: "var(--muted)" }}>Select an event to manage activities.</p>
</div>
);
}
@ -636,8 +815,8 @@ export function QuizPollsPage({
{!compactChrome ? (
<div style={{ display: "flex", alignItems: isMobile ? "stretch" : "flex-start", flexDirection: isMobile ? "column" : "row", justifyContent: "space-between", gap: "12px" }}>
<div>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "4px" }}>Quiz &amp; Polls</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: isMobile ? "var(--text-xl)" : "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Quiz &amp; Polls</h1>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "4px" }}>Activities</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: isMobile ? "var(--text-xl)" : "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Activities</h1>
</div>
{!showSessionPicker && (
<div style={{ display: "flex", flexDirection: "row", alignItems: "center", gap: "8px", flexWrap: isMobile ? "wrap" : "nowrap" }}>
@ -669,7 +848,7 @@ export function QuizPollsPage({
<SelectSessionCenter
dateGroups={dateGroups}
onSelectSession={(id) => setSessionId(id)}
selectPrompt="Select a session to manage quizzes and polls"
selectPrompt="Select a session to manage activities"
/>
)
) : (
@ -680,20 +859,144 @@ export function QuizPollsPage({
<span style={{ marginLeft: 8 }}>{currentSession.trackName}</span>
</p>
)}
{loadingPolls ? (
<div style={{ padding: 48, textAlign: "center", color: "var(--muted)" }}>Loading quizzes &amp; polls…</div>
{qnaManageOpen && includeQnASection && sessionId ? (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<button
type="button"
onClick={() => setQnaManageOpen(false)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
alignSelf: "flex-start",
padding: "6px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--foreground)",
}}
>
<ArrowLeft size={14} /> Back to Activities
</button>
<QuestionsPage fixedSessionId={sessionId} compactChrome />
</div>
) : loadingPolls ? (
<div style={{ padding: 48, textAlign: "center", color: "var(--muted)" }}>Loading activities…</div>
) : (
<>
{presentationMsg ? (
<p style={{ marginBottom: 12, fontSize: "var(--text-sm)", color: "var(--primary)", fontFamily: "var(--font-noto-sans)" }}>
{presentationMsg}
</p>
) : null}
<Section icon={<BarChart2 size={15} />} iconColor="#16a34a" title="Polls" count={polls.length} addLabel="Add Poll" onAdd={() => setEditor({ mode: "create", kind: "poll" })}>
{polls.map((p) => (
<ItemRow key={p.id} name={p.name} description={p.description} status={p.status} live={p.live} onToggleLive={() => togglePollLive(p.id)} onDuplicate={() => setDuplicateItem({ id: p.id, type: "poll" })} onEdit={() => setEditor({ mode: "edit", id: p.id })} onDelete={() => handleDeletePoll(p.id)} />
<ItemRow
key={p.id}
name={p.name}
description={p.description}
status={p.status}
live={p.live}
showLiveToggle={p.showLiveToggle}
showQuizMasterControls={p.showQuizMasterControls}
onToggleLive={() => void togglePollLive(p.id)}
onPresent={() => setPresenterQuizId(p.id)}
onMakeOffline={() => void makeQuizMasterOffline(p.id)}
onCopyPresentation={() => void copyPresentationLink(p.id)}
presentationCopied={copiedPresentationId === p.id}
onDuplicate={() => setDuplicateItem({ id: p.id, type: "poll" })}
onEdit={() => setEditor({ mode: "edit", id: p.id })}
onDelete={() => handleDeletePoll(p.id)}
/>
))}
</Section>
<Section icon={<CheckSquare size={15} />} iconColor="var(--primary)" title="Quizzes" count={quizzes.length} addLabel="Add Quiz" onAdd={() => setEditor({ mode: "create", kind: "quiz" })}>
{quizzes.map((q) => (
<ItemRow key={q.id} name={q.name} description={q.description} badge={q.type} status={q.status} live={q.live} onToggleLive={() => toggleQuizLive(q.id)} onDuplicate={() => setDuplicateItem({ id: q.id, type: "quiz" })} onEdit={() => setEditor({ mode: "edit", id: q.id })} onDelete={() => handleDeleteQuiz(q.id)} />
<ItemRow
key={q.id}
name={q.name}
description={q.description}
badge={q.type}
status={q.status}
live={q.live}
showLiveToggle={q.showLiveToggle}
showQuizMasterControls={q.showQuizMasterControls}
onToggleLive={() => void toggleQuizLive(q.id)}
onPresent={() => setPresenterQuizId(q.id)}
onMakeOffline={() => void makeQuizMasterOffline(q.id)}
onCopyPresentation={() => void copyPresentationLink(q.id)}
presentationCopied={copiedPresentationId === q.id}
onDuplicate={() => setDuplicateItem({ id: q.id, type: "quiz" })}
onEdit={() => setEditor({ mode: "edit", id: q.id })}
onDelete={() => handleDeleteQuiz(q.id)}
/>
))}
</Section>
{includeQnASection ? (
<Section
icon={<MessageSquare size={15} />}
iconColor="#7c3aed"
title="Q&A"
count={questionCount}
hideAdd
headerActions={
<div style={{ display: "flex", alignItems: "center", gap: "6px", flexWrap: "wrap" }}>
{enableQnAOn ? (
<>
<button
type="button"
disabled={qnaToggleBusy || loadingSession}
onClick={() => void toggleEnableQnA(false)}
style={{
...sectionActionBtn,
background: "var(--card)",
color: "var(--muted)",
border: "1px solid var(--border)",
cursor: qnaToggleBusy ? "wait" : "pointer",
}}
>
Disable
</button>
<button
type="button"
onClick={() => setQnaManageOpen(true)}
style={{ ...sectionActionBtn, background: "#7c3aed", color: "#fff" }}
>
Manage
</button>
</>
) : (
<button
type="button"
disabled={qnaToggleBusy || loadingSession || !apiSession}
onClick={() => void toggleEnableQnA(true)}
style={{
...sectionActionBtn,
background: "#7c3aed",
color: "#fff",
cursor: qnaToggleBusy || !apiSession ? "wait" : "pointer",
opacity: !apiSession ? 0.6 : 1,
}}
>
{qnaToggleBusy ? "Enabling…" : "Enable"}
</button>
)}
</div>
}
emptyBody={
<div style={{ padding: "16px 18px" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", lineHeight: 1.5 }}>
{enableQnAOn
? "Audience questions are enabled on the visitor session page. Use Manage to moderate submitted questions."
: "Controls whether attendees can ask and view questions on the visitor session page."}
</p>
</div>
}
/>
) : null}
</>
)}
</>
@ -726,6 +1029,20 @@ export function QuizPollsPage({
onDone={loadQuizPolls}
/>
)}
{presenterQuiz ? (
<QuizMasterPresenterPanel
quiz={presenterQuiz}
onClose={() => setPresenterQuizId(null)}
onUpdated={async () => {
loadQuizPolls();
}}
/>
) : null}
</div>
);
}
/** @deprecated Use EventActivitiesPage — kept for activity detail embed. */
export function QuizPollsPage(props: EventActivitiesPageProps = {}) {
return <EventActivitiesPage {...props} includeQnASection={props.includeQnASection ?? false} />;
}

View File

@ -0,0 +1,63 @@
import type { Question } from "./AddQuestionModal";
import type { QuizPollQuestion } from "../../../services/api/quizPolls";
import type { QuizPollModalForm } from "./AddQuizPollModal";
export function isPerQuestionTimeLimitMode(
form: Pick<QuizPollModalForm, "displayMode" | "timeRestricted">,
): boolean {
return form.displayMode === "quiz_master" && form.timeRestricted === "Yes";
}
export function apiQuestionsToModal(qs: QuizPollQuestion[] | undefined): Question[] {
if (!qs?.length) return [];
return qs.map((q, i) => {
const opts = (q.options || []).map((o, j) => ({
id: `o-${i}-${j}`,
text: o.text || "",
isCorrect: !!o.isCorrect,
imageUrl: o.imageUrl,
}));
const useImagesOptions = opts.some((o) => o.imageUrl);
return {
id: `q-${i}`,
text: q.question || "",
imageUrl: q.imageUrl,
useImagesOptions,
type: (q.type === "multiple"
? "Multiple Choice"
: q.type === "text" || q.type === "rating"
? "Single Choice"
: "Single Choice") as Question["type"],
order: i,
options: opts,
status: "Active",
timeLimit:
typeof q.timeLimit === "number" && Number.isFinite(q.timeLimit)
? Math.max(0, Math.floor(q.timeLimit))
: undefined,
};
});
}
export function modalQuestionToApi(q: Question, kind: "quiz" | "poll") {
const base = {
question: q.text,
imageUrl: q.imageUrl?.trim() ? q.imageUrl.trim() : null,
type: (q.type === "Single Choice" || q.type === "True / False"
? "single"
: q.type === "Multiple Choice"
? "multiple"
: "text") as "single" | "multiple" | "text" | "rating",
options: q.options?.map((o) => ({
text: o.text,
isCorrect: o.isCorrect,
imageUrl: o.imageUrl?.trim() ? o.imageUrl.trim() : null,
})),
required: true as const,
...(kind === "quiz" ? { points: 1 } : {}),
};
if (typeof q.timeLimit === "number" && Number.isFinite(q.timeLimit) && q.timeLimit > 0) {
return { ...base, timeLimit: Math.max(0, Math.floor(q.timeLimit)) };
}
return base;
}

View File

@ -1,26 +1,13 @@
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Settings, Download, Plus, X, Save, Trophy, Search } from "lucide-react";
import { Download, Plus, X, Trophy, Search } from "lucide-react";
import {
eventsApi,
rewardPointsApi,
type Event,
type RewardRules,
type LeaderboardRow,
} from "../../../services/api";
type RuleField = { key: keyof RewardRules; label: string; hint: string };
const RULE_FIELDS: RuleField[] = [
{ key: "registrationComplete", label: "Registration complete", hint: "Awarded once when someone joins the event" },
{ key: "questionAsk", label: "Ask a question", hint: "Per question (signed-in attendee)" },
{ key: "questionUpvote", label: "Upvote a question", hint: "Per upvote state change" },
{ key: "questionDownvote", label: "Downvote a question", hint: "Per downvote state change" },
{ key: "quizPollSubmitted", label: "Quiz submitted", hint: "When a visitor completes a quiz" },
{ key: "quizCorrectPerQuestion", label: "Correct quiz answer", hint: "Loyalty per correct objective question" },
{ key: "pollParticipation", label: "Poll / survey participation", hint: "When a visitor submits a poll or survey" },
{ key: "feedbackFormSubmitted", label: "Feedback form (reserved)", hint: "Wire when visitor feedback API exists" },
{ key: "ratingSubmitted", label: "Rating (reserved)", hint: "Wire when visitor rating API exists" },
];
type BoardRow = LeaderboardRow;
function displayName(p: LeaderboardRow): string {
const n = [p.firstName, p.lastName].filter(Boolean).join(" ").trim();
@ -29,30 +16,6 @@ function displayName(p: LeaderboardRow): string {
return "Participant";
}
function buildEventUpdatePayload(ev: Event, rewardRules: RewardRules): Record<string, unknown> {
return {
name: ev.name,
type: ev.type,
status: ev.status,
startDate: ev.startDate,
endDate: ev.endDate,
subtitle: ev.subtitle ?? null,
description: ev.description ?? null,
category: ev.category ?? null,
location: ev.location ?? null,
locationType: ev.locationType ?? undefined,
virtualLink: ev.virtualLink ?? null,
tags: ev.tags,
image: ev.image ?? null,
bannerImage: ev.bannerImage ?? null,
settings: ev.settings,
metadata: ev.metadata,
rewardRules,
};
}
type BoardRow = LeaderboardRow;
function Podium({ top3 }: { top3: BoardRow[] }) {
const [first, second, third] = [top3[0], top3[1], top3[2]];
const podiumOrder = [second, first, third];
@ -181,169 +144,6 @@ function Podium({ top3 }: { top3: BoardRow[] }) {
);
}
function SettingsModal({
initialRules,
onClose,
onSave,
saving,
}: {
initialRules: RewardRules;
onClose: () => void;
onSave: (r: RewardRules) => void;
saving: boolean;
}) {
const [fields, setFields] = useState<Record<string, string>>(() => {
const o: Record<string, string> = {};
for (const { key } of RULE_FIELDS) {
const v = initialRules[key];
o[key as string] = v != null && v !== "" ? String(v) : "";
}
return o;
});
const set = (key: string, val: string) => setFields((s) => ({ ...s, [key]: val }));
const handleSave = () => {
const out: RewardRules = {};
for (const { key } of RULE_FIELDS) {
const t = String(fields[key as string] ?? "").trim();
if (t === "") continue;
const n = Number(t);
if (Number.isFinite(n) && n >= 0) out[key] = n;
}
onSave(out);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(15,48,114,0.25)",
zIndex: 100,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
backdropFilter: "blur(2px)",
}}
>
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "100%",
maxWidth: "520px",
boxShadow: "var(--elevation-sm)",
display: "flex",
flexDirection: "column",
maxHeight: "90vh",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "18px 22px", borderBottom: "1px solid var(--border)" }}>
<div>
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>
Reward rules
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Set to 0 or leave blank to disable. Session and quiz settings can override later.
</p>
</div>
<button type="button" onClick={onClose} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", marginLeft: "12px" }}>
<X size={16} />
</button>
</div>
<div style={{ padding: "20px 22px", display: "flex", flexDirection: "column", gap: "12px", overflowY: "auto" }}>
{RULE_FIELDS.map((f) => (
<div key={f.key} style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{ flex: 1 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)", marginBottom: "1px" }}>{f.label}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{f.hint}</p>
</div>
<div style={{ display: "flex", alignItems: "stretch", flexShrink: 0 }}>
<input
type="number"
min={0}
max={10000}
value={fields[f.key as string] ?? ""}
onChange={(e) => set(f.key as string, e.target.value)}
style={{
width: "68px",
height: "34px",
padding: "0 8px",
borderRadius: "var(--radius-button) 0 0 var(--radius-button)",
border: "1px solid var(--border)",
borderRight: "none",
background: "var(--card)",
fontFamily: "var(--font-inter)",
fontSize: "var(--text-sm)",
color: "var(--foreground)",
outline: "none",
textAlign: "center",
}}
/>
<div
style={{
padding: "0 10px",
height: "34px",
display: "flex",
alignItems: "center",
background: "var(--secondary)",
border: "1px solid var(--border)",
borderRadius: "0 var(--radius-button) var(--radius-button) 0",
}}
>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", whiteSpace: "nowrap" }}>pts</span>
</div>
</div>
</div>
))}
</div>
<div style={{ padding: "14px 22px", borderTop: "1px solid var(--border)", display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
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",
}}
>
Cancel
</button>
<button
type="button"
disabled={saving}
onClick={handleSave}
style={{
display: "flex",
alignItems: "center",
gap: "5px",
padding: "8px 16px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: saving ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: saving ? 0.75 : 1,
}}
>
<Save size={13} /> Save
</button>
</div>
</div>
</div>
);
}
function AddPointsModal({
row,
onClose,
@ -480,13 +280,11 @@ function AddPointsModal({
}
export function RewardPointsPage({ eventId }: { eventId: string | null }) {
const [showSettings, setShowSettings] = useState(false);
const [addFor, setAddFor] = useState<BoardRow | null>(null);
const [event, setEvent] = useState<Event | null>(null);
const [rows, setRows] = useState<BoardRow[]>([]);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [savingRules, setSavingRules] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [search, setSearch] = useState("");
const [searchFocused, setSearchFocused] = useState(false);
@ -554,21 +352,6 @@ export function RewardPointsPage({ eventId }: { eventId: string | null }) {
URL.revokeObjectURL(url);
};
const saveRules = async (r: RewardRules) => {
if (!eventId || !event) return;
setSavingRules(true);
try {
const payload = buildEventUpdatePayload(event, r);
const res = await eventsApi.update(eventId, payload);
if (!res.error) {
setShowSettings(false);
await reload();
}
} finally {
setSavingRules(false);
}
};
const submitManualPoints = async (points: number, description: string) => {
if (!eventId || !addFor) return;
setAddLoading(true);
@ -629,24 +412,6 @@ export function RewardPointsPage({ eventId }: { eventId: string | null }) {
>
<Download size={13} /> Export CSV
</button>
<button
type="button"
onClick={() => setShowSettings(true)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "34px",
height: "34px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
color: "var(--muted)",
border: "1px solid var(--border)",
cursor: "pointer",
}}
>
<Settings size={14} />
</button>
</div>
</div>
@ -777,9 +542,6 @@ export function RewardPointsPage({ eventId }: { eventId: string | null }) {
)}
</div>
{showSettings && event && (
<SettingsModal initialRules={event.rewardRules || {}} onClose={() => setShowSettings(false)} onSave={saveRules} saving={savingRules} />
)}
{addFor && <AddPointsModal row={addFor} onClose={() => setAddFor(null)} onSubmit={submitManualPoints} loading={addLoading} />}
</div>
);

View File

@ -0,0 +1,316 @@
import React, { useEffect, useState } from "react";
import { Save, X } from "lucide-react";
import { eventsApi, type Event, type RewardRules } from "../../../services/api";
export type RuleField = { key: keyof RewardRules; label: string; hint: string };
export const REWARD_RULE_FIELDS: RuleField[] = [
{ key: "registrationComplete", label: "Registration complete", hint: "Awarded once when someone joins the event" },
{ key: "questionAsk", label: "Ask a question", hint: "Per question (signed-in attendee)" },
{ key: "questionUpvote", label: "Upvote a question", hint: "Per upvote state change" },
{ key: "questionDownvote", label: "Downvote a question", hint: "Per downvote state change" },
{ key: "quizPollSubmitted", label: "Quiz submitted", hint: "When a visitor completes a quiz" },
{ key: "quizCorrectPerQuestion", label: "Correct quiz answer", hint: "Loyalty per correct objective question" },
{ key: "pollParticipation", label: "Poll / survey participation", hint: "When a visitor submits a poll or survey" },
{ key: "feedbackFormSubmitted", label: "Feedback form (reserved)", hint: "Wire when visitor feedback API exists" },
{ key: "ratingSubmitted", label: "Rating (reserved)", hint: "Wire when visitor rating API exists" },
];
export function countConfiguredRewardRules(rules?: RewardRules | null): number {
if (!rules) return 0;
return REWARD_RULE_FIELDS.filter((f) => {
const v = rules[f.key];
return v != null && Number(v) > 0;
}).length;
}
export function buildEventUpdatePayload(ev: Event, rewardRules: RewardRules): Record<string, unknown> {
return {
name: ev.name,
type: ev.type,
status: ev.status,
startDate: ev.startDate,
endDate: ev.endDate,
subtitle: ev.subtitle ?? null,
description: ev.description ?? null,
category: ev.category ?? null,
location: ev.location ?? null,
locationType: ev.locationType ?? undefined,
virtualLink: ev.virtualLink ?? null,
tags: ev.tags,
image: ev.image ?? null,
bannerImage: ev.bannerImage ?? null,
settings: ev.settings,
metadata: ev.metadata,
rewardRules,
};
}
export function RewardRulesSettingsModal({
initialRules,
onClose,
onSave,
saving,
}: {
initialRules: RewardRules;
onClose: () => void;
onSave: (r: RewardRules) => void;
saving: boolean;
}) {
const [fields, setFields] = useState<Record<string, string>>(() => {
const o: Record<string, string> = {};
for (const { key } of REWARD_RULE_FIELDS) {
const v = initialRules[key];
o[key as string] = v != null && v !== "" ? String(v) : "";
}
return o;
});
const set = (key: string, val: string) => setFields((s) => ({ ...s, [key]: val }));
const handleSave = () => {
const out: RewardRules = {};
for (const { key } of REWARD_RULE_FIELDS) {
const t = String(fields[key as string] ?? "").trim();
if (t === "") continue;
const n = Number(t);
if (Number.isFinite(n) && n >= 0) out[key] = n;
}
onSave(out);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(15,48,114,0.25)",
zIndex: 201,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
backdropFilter: "blur(2px)",
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="reward-rules-title"
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
width: "100%",
maxWidth: "520px",
boxShadow: "var(--elevation-sm)",
display: "flex",
flexDirection: "column",
maxHeight: "90vh",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "18px 22px", borderBottom: "1px solid var(--border)" }}>
<div>
<p id="reward-rules-title" style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>
Reward rules
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Set to 0 or leave blank to disable. Session and quiz settings can override later.
</p>
</div>
<button type="button" onClick={onClose} aria-label="Close" style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", marginLeft: "12px" }}>
<X size={16} />
</button>
</div>
<div style={{ padding: "20px 22px", display: "flex", flexDirection: "column", gap: "12px", overflowY: "auto" }}>
{REWARD_RULE_FIELDS.map((f) => (
<div key={f.key} style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div style={{ flex: 1 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--foreground)", marginBottom: "1px" }}>{f.label}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{f.hint}</p>
</div>
<div style={{ display: "flex", alignItems: "stretch", flexShrink: 0 }}>
<input
type="number"
min={0}
max={10000}
value={fields[f.key as string] ?? ""}
onChange={(e) => set(f.key as string, e.target.value)}
style={{
width: "68px",
height: "34px",
padding: "0 8px",
borderRadius: "var(--radius-button) 0 0 var(--radius-button)",
border: "1px solid var(--border)",
borderRight: "none",
background: "var(--card)",
fontFamily: "var(--font-inter)",
fontSize: "var(--text-sm)",
color: "var(--foreground)",
outline: "none",
textAlign: "center",
}}
/>
<div
style={{
padding: "0 10px",
height: "34px",
display: "flex",
alignItems: "center",
background: "var(--secondary)",
border: "1px solid var(--border)",
borderRadius: "0 var(--radius-button) var(--radius-button) 0",
}}
>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", whiteSpace: "nowrap" }}>pts</span>
</div>
</div>
</div>
))}
</div>
<div style={{ padding: "14px 22px", borderTop: "1px solid var(--border)", display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
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",
}}
>
Cancel
</button>
<button
type="button"
disabled={saving}
onClick={handleSave}
style={{
display: "flex",
alignItems: "center",
gap: "5px",
padding: "8px 16px",
borderRadius: "var(--radius-button)",
background: "var(--primary)",
color: "var(--primary-foreground)",
border: "none",
cursor: saving ? "wait" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
opacity: saving ? 0.75 : 1,
}}
>
<Save size={13} /> Save
</button>
</div>
</div>
</div>
);
}
export function RewardRulesSettingsPanel({
eventId,
onClose,
onSaved,
}: {
eventId: string;
onClose: () => void;
onSaved?: () => void;
}) {
const [event, setEvent] = useState<Event | null>(null);
const [loading, setLoading] = useState(true);
const [loadErr, setLoadErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setLoadErr(null);
void eventsApi.get(eventId).then((res) => {
if (cancelled) return;
if (res.error || !res.data) {
setLoadErr(typeof res.error === "string" ? res.error : "Failed to load event.");
setEvent(null);
} else {
setEvent(res.data);
}
setLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
const saveRules = async (rules: RewardRules) => {
if (!event) return;
setSaving(true);
try {
const res = await eventsApi.update(eventId, buildEventUpdatePayload(event, rules));
if (!res.error) {
onSaved?.();
onClose();
}
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(15,48,114,0.25)",
zIndex: 201,
display: "flex",
alignItems: "center",
justifyContent: "center",
backdropFilter: "blur(2px)",
}}
>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)", background: "var(--card)", padding: "16px 24px", borderRadius: "var(--radius-card)", border: "1px solid var(--border)" }}>
Loading reward rules…
</p>
</div>
);
}
if (loadErr || !event) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(15,48,114,0.25)",
zIndex: 201,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
backdropFilter: "blur(2px)",
}}
>
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", padding: "20px 24px", maxWidth: 400, border: "1px solid var(--border)" }}>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--destructive)", margin: "0 0 12px" }}>{loadErr ?? "Event not found."}</p>
<button type="button" onClick={onClose} style={{ padding: "8px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", cursor: "pointer", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
Close
</button>
</div>
</div>
);
}
return (
<RewardRulesSettingsModal
initialRules={event.rewardRules || {}}
onClose={onClose}
onSave={saveRules}
saving={saving}
/>
);
}

View File

@ -2,7 +2,7 @@ import React from "react";
import {
House, Layers, Palette, Mic2, Users, FileText, BookOpen,
MessageSquare, MessageCircle, CreditCard, Shield, Settings, LayoutGrid,
Calendar, Award, HelpCircle, BarChart2, Globe,
Calendar, Award, HelpCircle, BarChart2, Globe, Ticket,
Plus, Upload, Building2, Zap, LineChart,
} from "lucide-react";
@ -81,13 +81,13 @@ export const ALL_PAGES: SearchPage[] = [
{ id: "p14", navId: "payment", title: "Payment Settings", section: "Portfolio", description: "Configure payment gateways and billing", icon: <CreditCard size={14} /> },
{ id: "p15", navId: "user-management", title: "User Management", section: "Portfolio", description: "Manage team members, roles, and permissions", icon: <Shield size={14} /> },
{ id: "p16", navId: "settings", title: "Settings", section: "General", description: "Organisation, profile, and platform preferences", icon: <Settings size={14} /> },
{ id: "p17", navId: "dashboard", title: "Dashboard", section: "Event", description: "Tracks, sessions, and schedule for the event", icon: <LayoutGrid size={14} /> },
{ id: "p18", navId: "analytics", title: "Event Analytics", section: "Event", description: "Stats, charts, quick actions, and feature overview", icon: <LineChart size={14} /> },
{ id: "p17", navId: "dashboard", title: "Event Home", section: "Event", description: "Tracks, sessions, and schedule for the event", icon: <LayoutGrid size={14} /> },
{ id: "p18", navId: "analytics", title: "Event Analytics", section: "Event", description: "Live stats, charts, and feature overview", icon: <LineChart size={14} /> },
{ id: "p19", navId: "participants", title: "Event Participants", section: "Event", description: "Registrations, check-ins, and attendance", icon: <Users size={14} /> },
{ id: "p20", navId: "permissions", title: "Permissions", section: "Event", description: "Control team access and event-level roles", icon: <Shield size={14} /> },
{ id: "p21", navId: "questions", title: "Q&A Questions", section: "Event", description: "Manage audience questions and Q&A sessions", icon: <HelpCircle size={14} /> },
{ id: "p22", navId: "quiz", title: "Quiz & Polls", section: "Event", description: "Create quizzes, polls, and live surveys", icon: <BarChart2 size={14} /> },
{ id: "p23", navId: "rewards", title: "Reward Points", section: "Event", description: "Configure and manage attendee rewards", icon: <Award size={14} /> },
{ id: "p20", navId: "permissions", title: "Users & Permissions", section: "Event", description: "Manage event team members, roles, and session access", icon: <Shield size={14} /> },
{ id: "p21", navId: "event-activities", title: "Activities", section: "Event", description: "Polls, quizzes, and audience Q&A for sessions", icon: <BarChart2 size={14} /> },
{ id: "p23", navId: "rewards", title: "Reward Points", section: "Event", description: "Leaderboard, manual points, and attendee rankings", icon: <Award size={14} /> },
{ id: "p24", navId: "ticketing", title: "Ticket configuration", section: "Event", description: "Configure ticket types, pricing, and UPI checkout", icon: <Ticket size={14} /> },
];
/* ─── Quick actions ─── */

View File

@ -380,7 +380,7 @@ export function WhatsAppChatbotsPage() {
}
/>
<span style={{ fontSize: 11, color: "var(--muted-foreground)" }}>
Use {"{profileName}"} and {"{eventName}"}
Use {"{profileName}"} (default: Visitor) and {"{eventName}"} (default: Event)
</span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>

View File

@ -27,6 +27,9 @@ interface AppNavContextValue {
/** Agenda opens new-session panel once after nav + selectedEventId. */
openAgendaSessionPanelOnce: boolean;
setOpenAgendaSessionPanelOnce: (v: boolean) => void;
/** Permissions opens add-users modal once after navigating from Event Home. */
permissionsOpenAddOnce: boolean;
setPermissionsOpenAddOnce: (v: boolean) => void;
}
/** Matches Settings sidebar ids — keep aligned with SettingsPage ALL_SECTIONS */
@ -59,6 +62,7 @@ export function AppNavProvider({ children, activeNavFromUrl, setActiveNavToUrl }
const [drawerOpen, setDrawerOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<PendingSettingsSection | null>(null);
const [openAgendaSessionPanelOnce, setOpenAgendaSessionPanelOnce] = useState(false);
const [permissionsOpenAddOnce, setPermissionsOpenAddOnce] = useState(false);
const [pendingMicrositeBuilder, setPendingMicrositeBuilder] = useState<PendingMicrositeBuilder | null>(null);
const activeNav = activeNavFromUrl ?? internalNav;
@ -79,6 +83,8 @@ export function AppNavProvider({ children, activeNavFromUrl, setActiveNavToUrl }
setSettingsInitialSection,
openAgendaSessionPanelOnce,
setOpenAgendaSessionPanelOnce,
permissionsOpenAddOnce,
setPermissionsOpenAddOnce,
pendingMicrositeBuilder,
setPendingMicrositeBuilder,
};

View File

@ -0,0 +1,24 @@
function publicWebBase(): string {
const fromEnv =
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim()) ||
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" && import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim());
if (fromEnv) return fromEnv.replace(/\/+$/, "");
// Dashboard (5173) is not the public web app; avoid using its origin for microsite links.
if (import.meta.env.DEV) return "http://localhost:5175";
return (typeof window !== "undefined" ? window.location.origin : "").replace(/\/+$/, "");
}
export function micrositePublicPageUrl(folder: string): string | null {
const base = (import.meta.env.VITE_MICROSITE_PUBLIC_BASE as string | undefined)?.replace(/\/+$/, "");
if (!base || !folder.trim()) return null;
const slug = folder.replace(/^\/+|\/+$/g, "");
return `${base}/${slug}/index.html`;
}
export function defaultMicrositePageUrl(eventId: string): string | null {
const base = publicWebBase();
if (!base || !eventId.trim()) return null;
return `${base}/event/${eventId}`;
}

View File

@ -0,0 +1,13 @@
function eventbuzzPublicBase(): string {
const fromEnv =
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" && import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
(typeof import.meta.env.VITE_PUBLIC_WEB_BASE === "string" && import.meta.env.VITE_PUBLIC_WEB_BASE.trim());
if (fromEnv) return fromEnv.replace(/\/+$/, "");
if (import.meta.env.DEV) return "http://localhost:5174";
return (typeof window !== "undefined" ? window.location.origin : "").replace(/\/+$/, "");
}
export function quizPollPresentationUrl(token: string): string {
const base = eventbuzzPublicBase();
return `${base}/quiz-present/${encodeURIComponent(token)}`;
}

View File

@ -0,0 +1,48 @@
import type { ApiSession, SessionPayload } from "../../services/api/sessions";
import type { ApiTrack } from "../../services/api/tracks";
/** Resolved Q&A flag (supports legacy `qnaEnabled` on older session documents). */
export function effectiveEnableQnA(s: ApiSession): boolean {
if (typeof s.enableQnA === "boolean") return s.enableQnA;
const legacy = (s as ApiSession & { qnaEnabled?: boolean }).qnaEnabled;
if (typeof legacy === "boolean") return legacy;
return true;
}
export function effectiveEnableQnP(s: ApiSession): boolean {
if (typeof s.enableQnP === "boolean") return s.enableQnP;
return true;
}
export function sessionToPayload(s: ApiSession): SessionPayload {
const trackRaw = s.track;
let track: string | null = null;
if (trackRaw && typeof trackRaw === "object" && "_id" in trackRaw) track = String((trackRaw as ApiTrack)._id);
else if (typeof trackRaw === "string" && trackRaw.trim()) track = trackRaw;
const speakers = (s.speakers || []).map((x) => (typeof x === "string" ? x : x._id));
return {
title: s.title,
description: s.description ?? "",
type: s.type,
startTime: s.startTime,
endTime: s.endTime,
location: s.location,
track,
speakers,
moderator: s.moderator ?? null,
capacity: s.capacity,
status: s.status,
resources: s.resources,
audienceFeedbackEnabled: s.audienceFeedbackEnabled,
audienceRatingsEnabled: s.audienceRatingsEnabled,
audienceKeyPersonsEnabled: s.audienceKeyPersonsEnabled,
audienceMySessionsEnabled: s.audienceMySessionsEnabled,
audienceAgendaEnabled: s.audienceAgendaEnabled,
audienceFeedbackForm: s.audienceFeedbackForm ?? null,
audienceRatingsForm: s.audienceRatingsForm ?? null,
enableQnA: effectiveEnableQnA(s),
enableQnP: effectiveEnableQnP(s),
};
}

View File

@ -23,8 +23,9 @@ export const NAV_IDS = {
AGENDA: 'agenda',
PARTICIPANTS: 'participants',
PERMISSIONS: 'permissions',
QUESTIONS: 'questions',
QUIZ: 'quiz',
EVENT_ACTIVITIES: 'event-activities',
QUESTIONS: 'event-activities',
QUIZ: 'event-activities',
REWARDS: 'rewards',
WEBSITE: 'website',
DESIGN: 'design-brochure',

View File

@ -17,6 +17,36 @@ export interface BadgeBatchRecord {
config?: Record<string, unknown>;
}
export interface BadgeLayoutElementPayload {
id: string;
kind: 'field' | 'custom_text' | 'custom_image';
field?: 'name' | 'title' | 'org' | 'badge_type' | 'qr' | 'photo';
text?: string;
imageUrl?: string;
imageAspectRatio?: number;
x: number;
y: number;
w?: number;
h?: number;
fontSizePct?: number;
fontFamily?: string;
color?: string;
fontWeight?: 'normal' | 'bold';
fullWidthCenter?: boolean;
shrinkToFit?: boolean;
textAlign?: 'left' | 'center' | 'right';
}
export interface BadgeCustomTextPayload {
id: string;
text: string;
}
export interface BadgeCustomImagePayload {
id: string;
imageUrl: string;
}
export interface CreateBadgeBatchPayload {
name: string;
organization?: string;
@ -33,6 +63,9 @@ export interface CreateBadgeBatchPayload {
backgroundImageUrl?: string;
backgroundColor?: string;
fields: string[];
layout?: BadgeLayoutElementPayload[];
customTexts?: BadgeCustomTextPayload[];
customImages?: BadgeCustomImagePayload[];
outputFormat: 'pdf' | 'png' | 'jpeg';
badgeTypeLabel?: string;
}

View File

@ -11,6 +11,14 @@ interface ListParams {
status?: string;
q?: string;
designation?: string;
tag?: string;
}
interface EventMatchingParams {
q?: string;
tag?: string;
status?: string;
designation?: string;
}
interface ListAllParams {
@ -52,19 +60,23 @@ export const participantsApi = {
},
bulk(payload: {
action: 'add_tags' | 'remove_tags' | 'assign_events' | 'add_to_fixed_list' | 'delete';
action: 'add_tags' | 'remove_tags' | 'assign_events' | 'add_to_fixed_list' | 'delete' | 'remove_from_event';
participantIds?: string[];
selectAllMatching?: boolean;
organization?: string;
q?: string;
tag?: string;
status?: string;
designation?: string;
tags?: string[];
eventIds?: string[];
eventId?: string;
listId?: string;
}) {
return api.post<{
updated?: number;
deleted?: number;
removed?: number;
membershipsAdded?: number;
listSize?: number;
}>('/participants/bulk', payload);
@ -77,6 +89,7 @@ export const participantsApi = {
if (params?.status) sp.set('status', params.status);
if (params?.q?.trim()) sp.set('q', params.q.trim());
if (params?.designation) sp.set('designation', params.designation);
if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
const query = sp.toString();
return api.get<{
participants: Participant[];
@ -86,6 +99,22 @@ export const participantsApi = {
}>(`/events/${eventId}/participants${query ? `?${query}` : ''}`);
},
listDistinctTagsForEvent(eventId: string) {
return api.get<{ tags: string[] }>(`/events/${eventId}/participants/tags`);
},
matchingIdsForEvent(eventId: string, params?: EventMatchingParams) {
const sp = new URLSearchParams();
if (params?.q?.trim()) sp.set('q', params.q.trim());
if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
if (params?.status) sp.set('status', params.status);
if (params?.designation) sp.set('designation', params.designation);
const qs = sp.toString();
return api.get<{ ids: string[]; total: number; capped: boolean }>(
`/events/${eventId}/participants/matching-ids${qs ? `?${qs}` : ''}`
);
},
get(id: string) {
return api.get<Participant>(`/participants/${id}`);
},

View File

@ -8,12 +8,15 @@ export interface QuizPollQuestionOption {
}
export interface QuizPollQuestion {
_id?: string;
question: string;
imageUrl?: string | null;
type: 'single' | 'multiple' | 'text' | 'rating';
options?: QuizPollQuestionOption[];
points?: number;
required?: boolean;
/** Per-question seconds (quiz master + time restricted) */
timeLimit?: number;
}
export type QuizPollDisplayMode = 'always_live' | 'always_sched' | 'manual' | 'quiz_master';
@ -35,6 +38,10 @@ export interface QuizPollSettings {
timeRestricted?: boolean;
/** Seconds (0 = no limit when not restricted) */
timeLimit?: number;
/** quiz_master: currently live question _id */
liveQuestionId?: string | null;
/** quiz_master: projector highlights correct answer for live question */
showCorrectAnswer?: boolean;
/** Optional loyalty overrides (merged with session → event). */
rewardRules?: RewardRules | null;
}
@ -86,4 +93,16 @@ export const quizPollsApi = {
close(id: string) {
return api.post<QuizPoll>(`/quiz-polls/${id}/close`, {});
},
setLiveQuestion(id: string, questionId: string | null) {
return api.post<QuizPoll>(`/quiz-polls/${id}/live-question`, { questionId });
},
setShowCorrect(id: string, show: boolean) {
return api.post<QuizPoll>(`/quiz-polls/${id}/show-correct`, { show });
},
ensurePresentationToken(id: string) {
return api.post<{ presentationToken: string; presentationUrl: string }>(
`/quiz-polls/${id}/presentation-token`,
{},
);
},
};

View File

@ -22,6 +22,12 @@ export interface ApiSession {
audienceFeedbackEnabled?: boolean;
/** When false, hide ratings entry points on the attendee session page (event must still enable ratings). */
audienceRatingsEnabled?: boolean;
/** When false, hide Key Persons tab on attendee session page. */
audienceKeyPersonsEnabled?: boolean;
/** When false, hide My Sessions tab on attendee session page. */
audienceMySessionsEnabled?: boolean;
/** When false, hide Agenda tab on attendee session page. */
audienceAgendaEnabled?: boolean;
/** When false, hide Questions tab on activity detail (default true for normal events). */
enableQnA?: boolean;
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
@ -46,6 +52,9 @@ export type SessionPayload = {
resources?: { name?: string; type?: string; url?: string }[];
audienceFeedbackEnabled?: boolean;
audienceRatingsEnabled?: boolean;
audienceKeyPersonsEnabled?: boolean;
audienceMySessionsEnabled?: boolean;
audienceAgendaEnabled?: boolean;
audienceFeedbackForm?: string | null;
audienceRatingsForm?: string | null;
enableQnA?: boolean;

View File

@ -339,11 +339,53 @@ export interface EventDashboardAnalytics {
sessionCount?: number;
upcomingSessionCount?: number;
quizPollCount?: number;
quizCount?: number;
pollCount?: number;
pointsAwarded?: number;
pointsRedeemed?: number;
totalRevenue?: number;
rating?: number;
ratingCount?: number;
registrationTrend?: { date: string; registrations: number; checkIns: number }[];
quizStats?: {
id: string;
title: string;
type: 'quiz' | 'poll' | 'survey';
responses: number;
avgScore: number | null;
questionCount: number;
}[];
pollSummaries?: {
id: string;
title: string;
totalVotes: number;
options: { label: string; count: number; pct: number }[];
}[];
participantBreakdown?: { label: string; count: number }[];
participantBreakdownSource?: 'tags' | 'status';
recentParticipants?: {
id: string;
name: string;
status: string;
checkedIn: boolean;
activityAt: string;
}[];
attendanceSummary?: { totalCheckedIn: number; uniqueParticipants: number };
formSubmissionTotal?: number;
ticketRevenueMinor?: number;
leaderboardTop?: {
participantId: string;
total: number;
firstName?: string;
lastName?: string;
email?: string;
}[];
agendaSummary?: {
sessionCount: number;
dayCount: number;
speakerCount: number;
todaySessions: { sessionId: string; time: string; title: string; track: string }[];
};
}
export interface Participant {

View File

@ -437,6 +437,24 @@ export function PublicFormView() {
const k = dataKey(field.label);
if (!k) return null;
const req = field.required;
if (field.type === "textarea") {
return (
<div key={k}>
<label style={labelStyle}>
{k}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</label>
<textarea
value={String(values[k] ?? "")}
onChange={(e) => setField(k, e.target.value)}
placeholder={field.placeholder}
required={!!req}
rows={4}
style={{ ...inputStyle, resize: "vertical", minHeight: 96 }}
/>
</div>
);
}
if (field.type === "text" || field.type === "email" || field.type === "phone") {
return (
<div key={k}>