1july
This commit is contained in:
parent
50cfd63778
commit
76cfbf5581
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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()}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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'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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)"; } }}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 & 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)",
|
||||
};
|
||||
|
|
@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,19 @@ function escapeHtml(s: string) {
|
|||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" }}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 & 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 & 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 & 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} />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 ─── */
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
{},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue