This commit is contained in:
Ravi Gulhane 2026-07-22 09:39:01 +05:30
parent bad7d38777
commit 3feb2ff20a
59 changed files with 4285 additions and 659 deletions

4
dist/index.html vendored
View File

@ -12,8 +12,8 @@
rel="stylesheet" rel="stylesheet"
/> />
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" crossorigin src="/assets/index-DJFhkFks.js"></script> <script type="module" crossorigin src="/assets/index-Dj8crrlq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css"> <link rel="stylesheet" crossorigin href="/assets/index-1SgcqEoJ.css">
</head> </head>
<body> <body>

View File

@ -38,6 +38,12 @@ export function AppShell() {
} }
}, [selectedEventId, activeNav, setActiveNav]); }, [selectedEventId, activeNav, setActiveNav]);
useEffect(() => {
if (activeNav === "dashboard" && selectedEventId) {
setSidebarCollapsed(true);
}
}, [activeNav, selectedEventId, setSidebarCollapsed]);
return ( return (
<div <div
style={{ style={{

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
import { useAppNav } from "../contexts/AppNavContext"; import { useAppNav } from "../contexts/AppNavContext";
import { useBreakpoint } from "../hooks/useBreakpoint"; import { useBreakpoint } from "../hooks/useBreakpoint";
import { CreateEventPanel } from "./panels/CreateEventPanel"; import { CreateEventPanel } from "./panels/CreateEventPanel";
import { WebsiteThemeSettingsPanel } from "./panels/WebsiteThemeSettingsPanel";
import { EventHomeHeaderActions } from "./dashboard/EventHomeHeaderActions"; import { EventHomeHeaderActions } from "./dashboard/EventHomeHeaderActions";
import { EventHomeModules } from "./dashboard/EventHomeModules"; import { EventHomeModules } from "./dashboard/EventHomeModules";
import { EventAnalyticsPage } from "./analytics/EventAnalyticsPage"; import { EventAnalyticsPage } from "./analytics/EventAnalyticsPage";
@ -49,6 +50,7 @@ export function MainContent() {
setSettingsInitialSection, setSettingsInitialSection,
} = useAppNav(); } = useAppNav();
const [editEventPanelOpen, setEditEventPanelOpen] = useState(false); const [editEventPanelOpen, setEditEventPanelOpen] = useState(false);
const [websiteThemePanelOpen, setWebsiteThemePanelOpen] = useState(false);
const bp = useBreakpoint(); const bp = useBreakpoint();
const [dashEvent, setDashEvent] = useState<Event | null>(null); const [dashEvent, setDashEvent] = useState<Event | null>(null);
const [dashLoading, setDashLoading] = useState(false); const [dashLoading, setDashLoading] = useState(false);
@ -197,6 +199,7 @@ export function MainContent() {
loading={dashLoading && !!selectedEventId} loading={dashLoading && !!selectedEventId}
canEdit={!!selectedEventId} canEdit={!!selectedEventId}
onEditEvent={() => selectedEventId && setEditEventPanelOpen(true)} onEditEvent={() => selectedEventId && setEditEventPanelOpen(true)}
onWebsiteSettings={() => selectedEventId && setWebsiteThemePanelOpen(true)}
/> />
{editEventPanelOpen && selectedEventId && ( {editEventPanelOpen && selectedEventId && (
<CreateEventPanel <CreateEventPanel
@ -208,6 +211,15 @@ export function MainContent() {
}} }}
/> />
)} )}
{websiteThemePanelOpen && selectedEventId && (
<WebsiteThemeSettingsPanel
eventId={selectedEventId}
onClose={() => setWebsiteThemePanelOpen(false)}
onSuccess={() => {
void loadDashboardData();
}}
/>
)}
<EventHomeModules /> <EventHomeModules />

View File

@ -11,7 +11,7 @@ const EVENT_TAB_MODULES: Record<string, string> = {
dashboard: "events", dashboard: "events",
agenda: "sessions", agenda: "sessions",
participants: "participants", participants: "participants",
attendance: "participants", attendance: "attendance",
"event-activities": "live_polls", "event-activities": "live_polls",
rewards: "gamification", rewards: "gamification",
analytics: "events", analytics: "events",

View File

@ -54,7 +54,6 @@ const PORTFOLIO_NAV: NavItem[] = [
id: "design", label: "Design", icon: <Palette size={16} />, id: "design", label: "Design", icon: <Palette size={16} />,
children: [ children: [
{ id: "design-brochure", label: "Brochure" }, { id: "design-brochure", label: "Brochure" },
{ id: "design-microsite", label: "Microsite Templates" },
{ id: "design-badges", label: "Badges" }, { id: "design-badges", label: "Badges" },
{ id: "design-emailers", label: "Emailers" }, { id: "design-emailers", label: "Emailers" },
{ id: "design-whatsapp-templates", label: "WhatsApp Templates" }, { id: "design-whatsapp-templates", label: "WhatsApp Templates" },
@ -459,28 +458,154 @@ function BottomUser({ collapsed, onNavigate }: { collapsed: boolean; onNavigate:
)} )}
{collapsed ? ( {collapsed ? (
<Tooltip label={`${displayName} · Log out`}> <>
<button type="button" onClick={handleLogout} style={{ ...btnStyle, padding: "6px 0" }} <Tooltip label={`${displayName} · Settings`}>
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }} <button
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}> type="button"
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}> onClick={() => onNavigate("settings")}
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span> style={{ ...btnStyle, padding: "6px 0" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--accent)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<div
style={{
width: "28px",
height: "28px",
borderRadius: "50%",
background: "var(--primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>
{initials}
</span>
</div>
</button>
</Tooltip>
<Tooltip label="Log out">
<button
type="button"
onClick={handleLogout}
style={btnStyle}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--accent)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span style={{ color: "var(--muted)", flexShrink: 0, display: "flex" }}>
<LogOut size={16} />
</span>
</button>
</Tooltip>
</>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 10px",
borderRadius: "var(--radius-button)",
}}
>
<button
type="button"
onClick={() => onNavigate("settings")}
title="Settings"
style={{
display: "flex",
alignItems: "center",
gap: "8px",
flex: 1,
minWidth: 0,
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
textAlign: "left",
}}
onMouseEnter={(e) => {
const nameEl = e.currentTarget.querySelector("[data-user-name]") as HTMLElement | null;
if (nameEl) nameEl.style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
const nameEl = e.currentTarget.querySelector("[data-user-name]") as HTMLElement | null;
if (nameEl) nameEl.style.color = "var(--sidebar-foreground)";
}}
>
<div
style={{
width: "28px",
height: "28px",
borderRadius: "50%",
background: "var(--primary)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>
{initials}
</span>
</div>
<div style={{ flex: 1, overflow: "hidden" }}>
<p
data-user-name
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--sidebar-foreground)",
lineHeight: "1.3",
margin: 0,
}}
>
{displayName}
</p>
<p
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
margin: 0,
}}
>
{email || "—"}
</p>
</div> </div>
</button> </button>
</Tooltip> <button
) : ( type="button"
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px", borderRadius: "var(--radius-button)" }}> onClick={handleLogout}
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> title="Log out"
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span> style={{
</div> background: "none",
<div style={{ flex: 1, overflow: "hidden" }}> border: "none",
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--sidebar-foreground)", lineHeight: "1.3", margin: 0 }}>{displayName}</p> cursor: "pointer",
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", margin: 0 }}>{email || "—"}</p> color: "var(--muted)",
</div> display: "flex",
<button type="button" onClick={handleLogout} title="Log out" padding: "2px",
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px", flexShrink: 0 }} flexShrink: 0,
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }} }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}> onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<LogOut size={13} /> <LogOut size={13} />
</button> </button>
</div> </div>

View File

@ -1,16 +1,17 @@
import React, { useState, useRef, useEffect, useMemo } from "react"; import React, { useState, useRef, useEffect, useMemo } from "react";
import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard } from "lucide-react"; import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard, ChevronDown } from "lucide-react";
import { useBreakpoint } from "../hooks/useBreakpoint"; import { useBreakpoint } from "../hooks/useBreakpoint";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { useAppNav } from "../contexts/AppNavContext"; import { useAppNav } from "../contexts/AppNavContext";
import { organizationApi, subscriptionApi } from "../../services/api"; import { organizationApi, subscriptionApi, eventsApi } from "../../services/api";
import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types"; import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types";
import { portfolioListOrganizationId } from "../../services/api/scopedOrganization";
import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents"; import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents";
import { import {
doSearch, SUGGESTED_SHORTCUTS, doSearch,
STATUS_BADGE, LOC_ICON, STATUS_BADGE,
mapApiEventToSearchEvent,
SearchEvent, SearchPage, SearchAction, SearchResults, SearchEvent, SearchPage, SearchAction, SearchResults,
ALL_EVENTS,
} from "./search/searchIndex"; } from "./search/searchIndex";
/* ───────────────────────────────────────── /* ─────────────────────────────────────────
@ -76,72 +77,9 @@ function SearchDropdown({
</div> </div>
); );
/* ── Suggested shortcuts (empty query) ── */ /* ── Empty query: no menu (avoid static Suggested / Recent Events panel) ── */
if (!trimmed) { if (!trimmed) {
return ( return null;
<div>
{/* Suggested chips */}
<div style={{ padding: "12px 14px 8px" }}>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: "8px" }}>
Suggested
</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{SUGGESTED_SHORTCUTS.map(s => (
<button
key={s.navId}
onClick={() => onPageClick({ id: s.navId, navId: s.navId, title: s.label, section: "", description: "", icon: s.icon })}
style={{
display: "inline-flex", alignItems: "center", gap: "5px",
padding: "4px 10px", borderRadius: "var(--radius-button)",
border: "1px solid var(--border)", background: "var(--secondary)",
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)", cursor: "pointer",
transition: "background 0.1s, border-color 0.1s",
}}
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)"; }}
>
<span style={{ color: "var(--primary)" }}>{s.icon}</span>
{s.label}
</button>
))}
</div>
</div>
{/* Divider */}
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0" }} />
{/* Recent events */}
<div style={{ padding: "4px 14px 10px" }}>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: "4px", paddingTop: "8px" }}>
Recent Events
</p>
{ALL_EVENTS.slice(0, 3).map(e => {
const badge = STATUS_BADGE[e.status];
return (
<button key={e.id} onClick={() => onEventClick(e)}
style={{
display: "flex", alignItems: "center", gap: "10px",
width: "100%", padding: "8px 0", textAlign: "left",
background: "none", border: "none", borderBottom: "1px solid var(--border)",
cursor: "pointer",
}}
onMouseEnter={e2 => { (e2.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e2.currentTarget as HTMLElement).style.borderRadius = "var(--radius-button)"; (e2.currentTarget as HTMLElement).style.paddingLeft = "6px"; }}
onMouseLeave={e2 => { (e2.currentTarget as HTMLElement).style.background = "none"; (e2.currentTarget as HTMLElement).style.paddingLeft = "0"; }}
>
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: badge.dot ?? badge.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{e.title}
</span>
<span style={{ padding: "1px 6px", borderRadius: "999px", background: badge.bg, color: badge.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0 }}>
{e.status}
</span>
</button>
);
})}
</div>
</div>
);
} }
/* ── No results ── */ /* ── No results ── */
@ -363,12 +301,138 @@ function formatRenewalSuffix(expiresAt: string | undefined): { desktop: string;
}; };
} }
function NotificationsBell() {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(true);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
return (
<div ref={wrapRef} style={{ position: "relative" }}>
<button
type="button"
style={{
position: "relative",
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: open ? "var(--secondary)" : "none",
border: "none",
cursor: "pointer",
color: open ? "var(--foreground)" : "var(--muted)",
borderRadius: "var(--radius-button)",
}}
aria-label="Notifications"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
}}
onMouseLeave={(e) => {
if (open) return;
(e.currentTarget as HTMLElement).style.background = "none";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<Bell size={16} />
</button>
{open && (
<div
role="dialog"
aria-label="Notifications"
style={{
position: "absolute",
top: "36px",
right: 0,
width: "min(320px, calc(100vw - 24px))",
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
boxShadow: "var(--elevation-sm)",
zIndex: 250,
overflow: "hidden",
}}
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
padding: "12px 14px",
background: "var(--secondary)",
border: "none",
borderBottom: expanded ? "1px solid var(--border)" : "none",
cursor: "pointer",
textAlign: "left",
}}
>
<span
style={{
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
color: "var(--foreground)",
}}
>
Notifications
</span>
<ChevronDown
size={14}
style={{
color: "var(--muted)",
transform: expanded ? "rotate(180deg)" : "none",
transition: "transform 0.15s",
flexShrink: 0,
}}
/>
</button>
{expanded && (
<div style={{ padding: "16px 14px" }}>
<p
style={{
margin: 0,
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--muted)",
textAlign: "center",
}}
>
No new notifications
</p>
</div>
)}
</div>
)}
</div>
);
}
function LoggedInUserButton({ isMobile }: { isMobile: boolean }) { function LoggedInUserButton({ isMobile }: { isMobile: boolean }) {
const { user } = useAuth(); const { user } = useAuth();
const { setActiveNav } = useAppNav();
const name = userDisplayName(user ?? null); const name = userDisplayName(user ?? null);
const initials = userInitials(user ?? null); const initials = userInitials(user ?? null);
return ( return (
<button <button
type="button"
aria-label={`Open settings for ${name}`}
title="Settings"
onClick={() => setActiveNav("settings")}
style={{ display: "flex", alignItems: "center", gap: isMobile ? "0" : "8px", padding: isMobile ? "4px" : "4px 8px 4px 4px", borderRadius: "var(--radius-button)", background: "none", border: "none", cursor: "pointer" }} style={{ display: "flex", alignItems: "center", gap: isMobile ? "0" : "8px", padding: isMobile ? "4px" : "4px 8px 4px 4px", borderRadius: "var(--radius-button)", background: "none", border: "none", cursor: "pointer" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}
@ -398,19 +462,47 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
const [planCatalog, setPlanCatalog] = useState<SubscriptionPlanCatalog[]>([]); const [planCatalog, setPlanCatalog] = useState<SubscriptionPlanCatalog[]>([]);
/** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */ /** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */
const [orgDataVersion, setOrgDataVersion] = useState(0); const [orgDataVersion, setOrgDataVersion] = useState(0);
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
const isTablet = bp === "tablet"; const isTablet = bp === "tablet";
const listOrgId = portfolioListOrganizationId(user);
const searchContainerRef = useRef<HTMLDivElement>(null); const searchContainerRef = useRef<HTMLDivElement>(null);
const desktopInputRef = useRef<HTMLInputElement>(null); const desktopInputRef = useRef<HTMLInputElement>(null);
const mobileInputRef = useRef<HTMLInputElement>(null); const mobileInputRef = useRef<HTMLInputElement>(null);
/* ── Current-org events for search (never cross-tenant demo data) ── */
useEffect(() => {
if (!listOrgId) {
setOrgSearchEvents([]);
return;
}
let cancelled = false;
void (async () => {
const res = await eventsApi.list({
organization: listOrgId,
recordKind: "event",
page: 1,
limit: 200,
});
if (cancelled) return;
if (res.error || !res.data?.events) {
setOrgSearchEvents([]);
return;
}
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
})();
return () => {
cancelled = true;
};
}, [listOrgId]);
/* ── Search results ── */ /* ── Search results ── */
const results: SearchResults | null = useMemo( const results: SearchResults | null = useMemo(
() => query.trim() ? doSearch(query) : null, () => (query.trim() ? doSearch(query, orgSearchEvents) : null),
[query], [query, orgSearchEvents],
); );
/* ── Flat items for keyboard nav ── */ /* ── Flat items for keyboard nav ── */
@ -498,13 +590,13 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
setTimeout(() => mobileInputRef.current?.focus(), 50); setTimeout(() => mobileInputRef.current?.focus(), 50);
} else { } else {
desktopInputRef.current?.focus(); desktopInputRef.current?.focus();
setDropdownOpen(true); if (query.trim()) setDropdownOpen(true);
} }
} }
}; };
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [isMobile]); }, [isMobile, query]);
/* ── Keyboard navigation inside input ── */ /* ── Keyboard navigation inside input ── */
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@ -617,14 +709,14 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
type="text" type="text"
value={query} value={query}
placeholder="Search events, pages, actions…" placeholder="Search events, pages, actions…"
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(true); }} onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(!!e.target.value.trim()); }}
onFocus={() => { setFocused(true); setDropdownOpen(true); }} onFocus={() => { setFocused(true); if (query.trim()) setDropdownOpen(true); }}
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }} style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
/> />
{query ? ( {query ? (
<button onClick={() => { setQuery(""); setActiveIndex(-1); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}> <button onClick={() => { setQuery(""); setActiveIndex(-1); setDropdownOpen(false); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}>
<X size={12} /> <X size={12} />
</button> </button>
) : ( ) : (
@ -634,8 +726,8 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
)} )}
</div> </div>
{/* ── Dropdown ── */} {/* ── Dropdown (only when there is a query) ── */}
{dropdownOpen && ( {dropdownOpen && query.trim() && (
<div <div
style={{ style={{
position: "absolute", top: "32px", left: 0, position: "absolute", top: "32px", left: 0,
@ -729,16 +821,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
</button> </button>
)} )}
{/* Bell */} <NotificationsBell />
<button
style={{ position: "relative", width: "32px", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", borderRadius: "var(--radius-button)" }}
aria-label="Notifications (3 unread)"
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
<Bell size={16} />
<span style={{ position: "absolute", top: "4px", right: "4px", minWidth: "14px", height: "14px", borderRadius: "999px", background: "var(--destructive)", border: "2px solid var(--card)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "8px", fontFamily: "var(--font-inter)", fontWeight: "700", color: "var(--destructive-foreground)", padding: "0 2px", lineHeight: 1 }}>3</span>
</button>
<div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} /> <div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} />
@ -786,18 +869,20 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
</button> </button>
</div> </div>
{/* Dropdown results */} {/* Dropdown results — only when typing */}
<div style={{ overflowY: "auto", flex: 1 }}> {query.trim() ? (
<SearchDropdown <div style={{ overflowY: "auto", flex: 1 }}>
query={query} <SearchDropdown
results={results} query={query}
activeIndex={activeIndex} results={results}
onEventClick={handleEventClick} activeIndex={activeIndex}
onPageClick={handlePageClick} onEventClick={handleEventClick}
onActionClick={handleActionClick} onPageClick={handlePageClick}
onViewAll={handleViewAll} onActionClick={handleActionClick}
/> onViewAll={handleViewAll}
</div> />
</div>
) : null}
</div> </div>
</div> </div>
)} )}

View File

@ -4,7 +4,7 @@
import React, { useState, useMemo, useEffect, useCallback } from "react"; import React, { useState, useMemo, useEffect, useCallback } from "react";
import { import {
Search, X, LayoutGrid, List, Calendar as CalIcon, Search, X, LayoutGrid, List, Calendar as CalIcon,
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, Star,
} from "lucide-react"; } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
@ -22,6 +22,7 @@ import {
} from "../quiz/AddQuizPollModal"; } from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal"; import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers"; import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
import { formatActivityRating, weightedActivityRating } from "./activityRatingDisplay";
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") { function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return modalQuestionToApi(q, kind); return modalQuestionToApi(q, kind);
@ -41,6 +42,10 @@ interface ActivityRow {
participants: number; participants: number;
questions: number; questions: number;
responses: number; responses: number;
eventRatingAverage: number;
eventRatingCount: number;
sessionRatingAverage: number;
sessionRatingCount: number;
subtype: "qna" | "quiz" | "poll"; subtype: "qna" | "quiz" | "poll";
raw: Event; raw: Event;
} }
@ -131,6 +136,10 @@ function eventToRow(ev: Event): ActivityRow {
participants: st?.participantCount ?? 0, participants: st?.participantCount ?? 0,
questions: st?.questionCount ?? 0, questions: st?.questionCount ?? 0,
responses: st?.responseCount ?? 0, responses: st?.responseCount ?? 0,
eventRatingAverage: st?.eventRatingAverage ?? 0,
eventRatingCount: st?.eventRatingCount ?? 0,
sessionRatingAverage: st?.sessionRatingAverage ?? 0,
sessionRatingCount: st?.sessionRatingCount ?? 0,
subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna", subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna",
raw: ev, raw: ev,
}; };
@ -391,11 +400,19 @@ export function ActivitiesPage({
const totals = useMemo(() => { const totals = useMemo(() => {
const base = rows; const base = rows;
const eventRating = weightedActivityRating(
base.map((a) => ({ average: a.eventRatingAverage, count: a.eventRatingCount })),
);
const sessionRating = weightedActivityRating(
base.map((a) => ({ average: a.sessionRatingAverage, count: a.sessionRatingCount })),
);
return { return {
count: base.length, count: base.length,
active: base.filter(a => a.status === "Active").length, active: base.filter(a => a.status === "Active").length,
participants: base.reduce((s, a) => s + a.participants, 0), participants: base.reduce((s, a) => s + a.participants, 0),
responses: base.reduce((s, a) => s + a.responses, 0), responses: base.reduce((s, a) => s + a.responses, 0),
eventRating,
sessionRating,
}; };
}, [rows]); }, [rows]);
@ -474,11 +491,23 @@ export function ActivitiesPage({
{!loading && orgId ? ( {!loading && orgId ? (
<> <>
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(4, 1fr)", gap: "12px" }}> <div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(3, 1fr)", gap: "12px" }}>
<StatCard label="Total Activities" value={String(totals.count)} /> <StatCard label="Total Activities" value={String(totals.count)} />
<StatCard label="Active" value={String(totals.active)} color="#15803d" /> <StatCard label="Active" value={String(totals.active)} color="#15803d" />
<StatCard label="Total Participants" value={String(totals.participants)} /> <StatCard label="Total Participants" value={String(totals.participants)} />
<StatCard label="Total Responses" value={String(totals.responses)} /> <StatCard label="Total Responses" value={String(totals.responses)} />
<StatCard
label="Event Rating"
value={formatActivityRating(totals.eventRating.average, totals.eventRating.count)}
sublabel={totals.eventRating.count ? `${totals.eventRating.count} ratings` : "No ratings yet"}
color="#d97706"
/>
<StatCard
label="Session Rating"
value={formatActivityRating(totals.sessionRating.average, totals.sessionRating.count)}
sublabel={totals.sessionRating.count ? `${totals.sessionRating.count} ratings` : "No ratings yet"}
color="#d97706"
/>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
@ -704,7 +733,7 @@ export function ActivitiesPage({
); );
} }
function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { function StatCard({ label, value, color, sublabel }: { label: string; value: string; color?: string; sublabel?: string }) {
return ( return (
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "16px 20px" }}> <div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "16px 20px" }}>
<div style={{ fontSize: "var(--text-2xl)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: color || "var(--foreground)", marginBottom: "4px" }}> <div style={{ fontSize: "var(--text-2xl)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: color || "var(--foreground)", marginBottom: "4px" }}>
@ -713,6 +742,11 @@ function StatCard({ label, value, color }: { label: string; value: string; color
<div style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.5px" }}> <div style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
{label} {label}
</div> </div>
{sublabel ? (
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "6px" }}>
{sublabel}
</div>
) : null}
</div> </div>
); );
} }
@ -753,7 +787,7 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
</p> </p>
) : null} ) : null}
<div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}> <div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)", flexWrap: "wrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}> <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Users size={14} style={{ color: "var(--muted)" }} /> <Users size={14} style={{ color: "var(--muted)" }} />
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.participants}</span> <span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.participants}</span>
@ -766,6 +800,18 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
<CheckCircle2 size={14} style={{ color: "var(--muted)" }} /> <CheckCircle2 size={14} style={{ color: "var(--muted)" }} />
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.responses}</span> <span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.responses}</span>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Event rating">
<Star size={14} style={{ color: "#d97706" }} />
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
{formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Session rating">
<Star size={14} style={{ color: "#d97706", opacity: 0.75 }} />
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
{formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
</span>
</div>
</div> </div>
</div> </div>
); );
@ -802,10 +848,16 @@ function ActivityListRow({ activity, isLast, onClick }: { activity: ActivityRow;
{activity.type} {activity.type}
</div> </div>
<div style={{ display: "flex", gap: "12px", minWidth: "120px" }}> <div style={{ display: "flex", gap: "12px", minWidth: "120px", flexWrap: "wrap" }}>
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}> <div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
{activity.participants} participants {activity.participants} participants
</div> </div>
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
Event {formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
</div>
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
Session {formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
</div>
</div> </div>
<span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase" }}> <span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase" }}>

View File

@ -2,7 +2,7 @@
* Activity detail — single-session activity shell with tabs (Questions, Quiz & Polls, Participants, Settings). * Activity detail — single-session activity shell with tabs (Questions, Quiz & Polls, Participants, Settings).
*/ */
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { QrCode } from "lucide-react"; import { QrCode, Users, HelpCircle, CheckCircle2, Star } from "lucide-react";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { eventsApi } from "../../../services/api/events"; import { eventsApi } from "../../../services/api/events";
@ -23,6 +23,7 @@ import {
} from "../quiz/AddQuizPollModal"; } from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal"; import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers"; import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
import { formatActivityRating } from "./activityRatingDisplay";
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") { function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return modalQuestionToApi(q, kind); return modalQuestionToApi(q, kind);
@ -61,7 +62,7 @@ export function ActivityDetailPage() {
setLoadError(null); setLoadError(null);
try { try {
const [evRes, sesRes] = await Promise.all([ const [evRes, sesRes] = await Promise.all([
eventsApi.get(selectedEventId), eventsApi.get(selectedEventId, { enrich: true }),
sessionsApi.list(selectedEventId), sessionsApi.list(selectedEventId),
]); ]);
if (evRes.error || !evRes.data) { if (evRes.error || !evRes.data) {
@ -105,6 +106,7 @@ export function ActivityDetailPage() {
}, [tabs, activeTab]); }, [tabs, activeTab]);
const progress = event ? activityProgress(event) : 0; const progress = event ? activityProgress(event) : 0;
const activityStats = event?.activityStats;
const enableQnAOn = session ? effectiveEnableQnA(session) : true; const enableQnAOn = session ? effectiveEnableQnA(session) : true;
const enableQnPOn = session ? effectiveEnableQnP(session) : true; const enableQnPOn = session ? effectiveEnableQnP(session) : true;
const subtype = event?.activitySubtype || "qna"; const subtype = event?.activitySubtype || "qna";
@ -272,6 +274,35 @@ export function ActivityDetailPage() {
</button> </button>
</div> </div>
{activityStats ? (
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(6, minmax(0, 1fr))",
gap: "10px",
}}
>
<ActivityMetric label="Participants" value={String(activityStats.participantCount ?? 0)} icon={<Users size={14} />} />
<ActivityMetric label="Questions" value={String(activityStats.questionCount ?? 0)} icon={<HelpCircle size={14} />} />
<ActivityMetric label="Responses" value={String(activityStats.responseCount ?? 0)} icon={<CheckCircle2 size={14} />} />
<ActivityMetric
label="Event Rating"
value={formatActivityRating(activityStats.eventRatingAverage, activityStats.eventRatingCount)}
sublabel={activityStats.eventRatingCount ? `${activityStats.eventRatingCount} ratings` : undefined}
icon={<Star size={14} />}
accent="#d97706"
/>
<ActivityMetric
label="Session Rating"
value={formatActivityRating(activityStats.sessionRatingAverage, activityStats.sessionRatingCount)}
sublabel={activityStats.sessionRatingCount ? `${activityStats.sessionRatingCount} ratings` : undefined}
icon={<Star size={14} />}
accent="#d97706"
/>
<ActivityMetric label="Engagement" value={`${progress}%`} />
</div>
) : null}
<div style={{ borderBottom: "1px solid var(--border)" }}> <div style={{ borderBottom: "1px solid var(--border)" }}>
<div style={{ display: "flex", gap: isMobile ? "8px" : "0", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: isMobile ? "8px" : "0", flexWrap: "wrap" }}>
{tabs.map((tab) => ( {tabs.map((tab) => (
@ -435,3 +466,50 @@ export function ActivityDetailPage() {
</div> </div>
); );
} }
function ActivityMetric({
label,
value,
sublabel,
icon,
accent,
}: {
label: string;
value: string;
sublabel?: string;
icon?: React.ReactNode;
accent?: string;
}) {
return (
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
border: "1px solid var(--border)",
padding: "12px 14px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}>
{icon ? <span style={{ color: accent || "var(--muted)", display: "flex" }}>{icon}</span> : null}
<span
style={{
fontSize: "var(--text-lg)",
fontFamily: "var(--font-inter)",
fontWeight: "var(--font-weight-medium)",
color: accent || "var(--foreground)",
}}
>
{value}
</span>
</div>
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.4px" }}>
{label}
</div>
{sublabel ? (
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{sublabel}
</div>
) : null}
</div>
);
}

View File

@ -520,12 +520,24 @@ export function ActivityEventSettings({
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option> <option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
<option>Open</option> <option>Open</option>
<option>Invite Only</option> <option>Invite Only</option>
<option>Approval Required</option>
</select> </select>
</SelectWrap> </SelectWrap>
</Field> </Field>
</Row> </Row>
<Row> <Row>
<Field>
<Label text="Registration Approval" />
<RadioGroup
name="registrationApproval"
value={form.registrationApproval}
onChange={(v) => f("registrationApproval", v as "auto" | "manual")}
options={[
{ label: "Auto Confirm", value: "auto" },
{ label: "Manual Confirm", value: "manual" },
]}
disabled={form.registrationRequired !== "Yes"}
/>
</Field>
<Field> <Field>
<Label text="Verify Email" /> <Label text="Verify Email" />
<RadioGroup <RadioGroup
@ -538,6 +550,8 @@ export function ActivityEventSettings({
]} ]}
/> />
</Field> </Field>
</Row>
<Row>
<Field> <Field>
<Label text="Registration Deadline" /> <Label text="Registration Deadline" />
<input type="date" style={IS} value={form.registrationDeadline} onChange={(e) => f("registrationDeadline", e.target.value)} /> <input type="date" style={IS} value={form.registrationDeadline} onChange={(e) => f("registrationDeadline", e.target.value)} />

View File

@ -0,0 +1,22 @@
/** Format average star rating for activity analytics (e.g. "4.2" or "—"). */
export function formatActivityRating(average: number | undefined | null, count: number | undefined | null): string {
const c = count ?? 0;
if (!c || average == null || Number.isNaN(average)) return "—";
return average.toFixed(1);
}
/** Weighted average across activities for portfolio-level stat cards. */
export function weightedActivityRating(
items: { average: number; count: number }[],
): { average: number | null; count: number } {
let sum = 0;
let total = 0;
for (const item of items) {
if (item.count > 0 && item.average != null && !Number.isNaN(item.average)) {
sum += item.average * item.count;
total += item.count;
}
}
if (!total) return { average: null, count: 0 };
return { average: Math.round((sum / total) * 10) / 10, count: total };
}

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, useEffect } from "react"; import React, { useState, useCallback, useMemo, useEffect } from "react";
import { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search } from "lucide-react"; import { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search, Trash2 } from "lucide-react";
import { import {
tracksApi, tracksApi,
sessionsApi, sessionsApi,
@ -8,6 +8,7 @@ import {
type ApiTrack, type ApiTrack,
type ApiSession, type ApiSession,
type SessionPayload, type SessionPayload,
type SessionTopic,
type Speaker, type Speaker,
} from "../../../services/api"; } from "../../../services/api";
import type { FormWithSubmissionCount } from "../../../services/api/types"; import type { FormWithSubmissionCount } from "../../../services/api/types";
@ -125,6 +126,38 @@ function StepCircle({ n, state }: { n: number; state: "todo" | "active" | "done"
type SectionErr = Record<string, string>; type SectionErr = Record<string, string>;
type TopicRow = {
type: string;
duration: string;
title: string;
role: string;
};
function emptyTopicRow(): TopicRow {
return { type: "", duration: "", title: "", role: "" };
}
function topicsFromSession(session?: ApiSession | null): TopicRow[] {
if (!session?.topics?.length) return [];
return session.topics.map((t) => ({
type: t.type || "",
duration: t.duration || "",
title: t.title || "",
role: t.role || "",
}));
}
function normalizeTopicsForPayload(rows: TopicRow[]): SessionTopic[] {
return rows
.map((t) => ({
type: t.type.trim(),
duration: t.duration.trim(),
title: t.title.trim(),
role: t.role.trim(),
}))
.filter((t) => t.type || t.duration || t.title || t.role);
}
function extractSpeakerIds(s?: ApiSession | null): string[] { function extractSpeakerIds(s?: ApiSession | null): string[] {
if (!s?.speakers?.length) return []; if (!s?.speakers?.length) return [];
return s.speakers.map((sp) => return s.speakers.map((sp) =>
@ -201,6 +234,7 @@ export function AgendaSessionPanel({
const [endLocal, setEndLocal] = useState(session?.endTime ? toDatetimeLocalValue(session.endTime) : def.end); const [endLocal, setEndLocal] = useState(session?.endTime ? toDatetimeLocalValue(session.endTime) : def.end);
const [description, setDescription] = useState(session?.description || ""); const [description, setDescription] = useState(session?.description || "");
const [topics, setTopics] = useState<TopicRow[]>(() => topicsFromSession(session));
const audienceOn = (v: boolean | undefined) => v !== false; const audienceOn = (v: boolean | undefined) => v !== false;
const [audienceFeedbackEnabled, setAudienceFeedbackEnabled] = useState(() => const [audienceFeedbackEnabled, setAudienceFeedbackEnabled] = useState(() =>
@ -341,6 +375,15 @@ export function AgendaSessionPanel({
const [err2, setErr2] = useState<SectionErr>({}); const [err2, setErr2] = useState<SectionErr>({});
const [err3, setErr3] = useState<SectionErr>({}); const [err3, setErr3] = useState<SectionErr>({});
const [err4, setErr4] = useState<SectionErr>({}); const [err4, setErr4] = useState<SectionErr>({});
const [err5, setErr5] = useState<SectionErr>({});
const updateTopic = (idx: number, key: keyof TopicRow, value: string) => {
setTopics((prev) => prev.map((row, i) => (i === idx ? { ...row, [key]: value } : row)));
};
const addTopic = () => setTopics((prev) => [...prev, emptyTopicRow()]);
const removeTopic = (idx: number) => setTopics((prev) => prev.filter((_, i) => i !== idx));
const validate1 = useCallback((): boolean => { const validate1 = useCallback((): boolean => {
const e: SectionErr = {}; const e: SectionErr = {};
@ -374,7 +417,15 @@ export function AgendaSessionPanel({
return true; return true;
}, []); }, []);
const validateAll = useCallback(() => validate1() && validate2() && validate3() && validate4(), [validate1, validate2, validate3, validate4]); const validate5 = useCallback((): boolean => {
setErr5({});
return true;
}, []);
const validateAll = useCallback(
() => validate1() && validate2() && validate3() && validate4() && validate5(),
[validate1, validate2, validate3, validate4, validate5],
);
const createInlineTrack = async () => { const createInlineTrack = async () => {
const e: SectionErr = {}; const e: SectionErr = {};
@ -406,8 +457,9 @@ export function AgendaSessionPanel({
if (section === 2 && !validate2()) return; if (section === 2 && !validate2()) return;
if (section === 3 && !validate3()) return; if (section === 3 && !validate3()) return;
if (section === 4 && !validate4()) return; if (section === 4 && !validate4()) return;
if (section === 5 && !validate5()) return;
setPassedSections((prev) => new Set([...prev, section])); setPassedSections((prev) => new Set([...prev, section]));
if (section < 4) setOpenSection(section + 1); if (section < 5) setOpenSection(section + 1);
}; };
const submit = async () => { const submit = async () => {
@ -421,10 +473,13 @@ export function AgendaSessionPanel({
if (!validate1()) setOpenSection(1); if (!validate1()) setOpenSection(1);
else if (!validate2()) setOpenSection(2); else if (!validate2()) setOpenSection(2);
else if (!validate3()) setOpenSection(3); else if (!validate3()) setOpenSection(3);
else setOpenSection(4); else if (!validate4()) setOpenSection(4);
else setOpenSection(5);
return; return;
} }
const normalizedTopics = normalizeTopicsForPayload(topics);
const payload: SessionPayload = { const payload: SessionPayload = {
title: title.trim(), title: title.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
@ -434,6 +489,7 @@ export function AgendaSessionPanel({
status: UI_TO_API_STATUS[status], status: UI_TO_API_STATUS[status],
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId, track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
speakers: selectedSpeakerIds, speakers: selectedSpeakerIds,
topics: normalizedTopics.length ? normalizedTopics : [],
audienceFeedbackEnabled, audienceFeedbackEnabled,
audienceRatingsEnabled, audienceRatingsEnabled,
audienceKeyPersonsEnabled, audienceKeyPersonsEnabled,
@ -460,7 +516,8 @@ export function AgendaSessionPanel({
{ n: 1, title: "Track", subtitle: "Assign this session to a track. You can create a new track inline." }, { n: 1, title: "Track", subtitle: "Assign this session to a track. You can create a new track inline." },
{ n: 2, title: "Session details", subtitle: "Title, format, and status." }, { n: 2, title: "Session details", subtitle: "Title, format, and status." },
{ n: 3, title: "Schedule", subtitle: "Start and end date/time." }, { n: 3, title: "Schedule", subtitle: "Start and end date/time." },
{ n: 4, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." }, { n: 4, title: "Session topics", subtitle: "Optional programme items within this session (type, duration, title, role)." },
{ n: 5, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." },
]; ];
const SaveBtn = () => ( const SaveBtn = () => (
@ -750,6 +807,105 @@ export function AgendaSessionPanel({
)} )}
{sec.n === 4 && ( {sec.n === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)", lineHeight: 1.45 }}>
Add programme items for this session block — e.g. lectures, panel discussions, and speaker roles.
</p>
{topics.length === 0 ? (
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
No topics yet. Add one below.
</p>
) : null}
{topics.map((topic, idx) => (
<div
key={idx}
style={{
border: "1px solid var(--border)",
borderLeft: "3px solid color-mix(in srgb, var(--primary) 35%, var(--border))",
borderRadius: "var(--radius-button)",
padding: "12px",
display: "flex",
flexDirection: "column",
gap: "10px",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>
Topic {idx + 1}
</span>
<button
type="button"
onClick={() => removeTopic(idx)}
aria-label="Remove topic"
style={{ border: "none", background: "none", cursor: "pointer", color: "var(--destructive)", display: "flex" }}
>
<Trash2 size={14} />
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic type</label>
<input
value={topic.type}
onChange={(e) => updateTopic(idx, "type", e.target.value)}
placeholder="e.g. LECTURE, Panel Discussion"
style={IS}
/>
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Duration</label>
<input
value={topic.duration}
onChange={(e) => updateTopic(idx, "duration", e.target.value)}
placeholder="e.g. 15 or 5+5"
style={IS}
/>
</div>
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic title</label>
<input
value={topic.title}
onChange={(e) => updateTopic(idx, "title", e.target.value)}
placeholder="Talk or segment title"
style={IS}
/>
</div>
<div>
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic role</label>
<input
value={topic.role}
onChange={(e) => updateTopic(idx, "role", e.target.value)}
placeholder="e.g. Moderator, Speaker, Panellist"
style={IS}
/>
</div>
</div>
))}
<button
type="button"
onClick={addTopic}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
alignSelf: "flex-start",
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: "1px dashed var(--border)",
background: "transparent",
cursor: "pointer",
fontSize: "var(--text-sm)",
color: "var(--primary)",
fontFamily: "var(--font-noto-sans)",
}}
>
<Plus size={14} /> Add session topic
</button>
</div>
)}
{sec.n === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<div> <div>
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Speakers</label> <label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Speakers</label>
@ -1124,7 +1280,7 @@ export function AgendaSessionPanel({
)} )}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}> <div style={{ display: "flex", justifyContent: "flex-end", marginTop: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}>
{sec.n < 4 ? ( {sec.n < 5 ? (
<button <button
type="button" type="button"
onClick={() => tryNext(sec.n)} onClick={() => tryNext(sec.n)}

View File

@ -14,6 +14,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { usePermission } from "../../contexts/PermissionContext";
import { attendancesApi } from "../../../services/api"; import { attendancesApi } from "../../../services/api";
import type { import type {
AttendanceRecord, AttendanceRecord,
@ -53,6 +54,10 @@ const emptyForm: FormState = {
export function AttendancePage() { export function AttendancePage() {
const { selectedEventId } = useAppNav(); const { selectedEventId } = useAppNav();
const { canModule } = usePermission();
const canReadAttendance = canModule("attendance", "read");
const canWriteAttendance = canModule("attendance", "write");
const canDeleteAttendance = canModule("attendance", "delete");
const [sessions, setSessions] = useState<AttendanceRecord[]>([]); const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -75,7 +80,7 @@ export function AttendancePage() {
const selected = sessions.find((s) => s._id === selectedId) ?? null; const selected = sessions.find((s) => s._id === selectedId) ?? null;
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
if (!selectedEventId) return; if (!selectedEventId || !canReadAttendance) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await attendancesApi.list(selectedEventId); const res = await attendancesApi.list(selectedEventId);
@ -85,7 +90,7 @@ export function AttendancePage() {
return; return;
} }
setSessions(res.data?.attendances ?? []); setSessions(res.data?.attendances ?? []);
}, [selectedEventId]); }, [selectedEventId, canReadAttendance]);
useEffect(() => { useEffect(() => {
loadSessions(); loadSessions();
@ -226,6 +231,14 @@ export function AttendancePage() {
); );
} }
if (!canReadAttendance) {
return (
<div style={{ padding: 24, color: "var(--muted)", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
You do not have permission to view Attendance.
</div>
);
}
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}> <div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
<div <div
@ -252,24 +265,26 @@ export function AttendancePage() {
Attendance Attendance
</h1> </h1>
</div> </div>
<button {canWriteAttendance && (
type="button" <button
onClick={openCreate} type="button"
style={{ onClick={openCreate}
display: "inline-flex", style={{
alignItems: "center", display: "inline-flex",
gap: 6, alignItems: "center",
padding: "8px 14px", gap: 6,
background: "var(--primary)", padding: "8px 14px",
color: "#fff", background: "var(--primary)",
border: "none", color: "#fff",
borderRadius: "var(--radius-button)", border: "none",
cursor: "pointer", borderRadius: "var(--radius-button)",
fontSize: "var(--text-sm)", cursor: "pointer",
}} fontSize: "var(--text-sm)",
> }}
<Plus size={14} /> Add session >
</button> <Plus size={14} /> Add session
</button>
)}
</div> </div>
{error && ( {error && (
@ -370,14 +385,16 @@ export function AttendancePage() {
)} )}
</div> </div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button {canWriteAttendance && (
type="button" <button
onClick={() => openEdit(selected)} type="button"
title="Edit" onClick={() => openEdit(selected)}
style={iconBtn} title="Edit"
> style={iconBtn}
<Pencil size={14} /> >
</button> <Pencil size={14} />
</button>
)}
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}> <button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
<Download size={14} /> <Download size={14} />
</button> </button>
@ -390,17 +407,21 @@ export function AttendancePage() {
> >
<Link2 size={14} /> <Link2 size={14} />
</button> </button>
<button {canWriteAttendance && (
type="button" <button
onClick={() => regenerateLink(selected._id)} type="button"
title="Regenerate link" onClick={() => regenerateLink(selected._id)}
style={iconBtn} title="Regenerate link"
> style={iconBtn}
<RefreshCw size={14} /> >
</button> <RefreshCw size={14} />
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}> </button>
<Trash2 size={14} /> )}
</button> {canDeleteAttendance && (
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
<Trash2 size={14} />
</button>
)}
</div> </div>
</div> </div>
@ -449,11 +470,11 @@ export function AttendancePage() {
</div> </div>
<button <button
type="button" type="button"
disabled={!canCheckIn || checkInBusy === p._id} disabled={!canWriteAttendance || !canCheckIn || checkInBusy === p._id}
onClick={() => handleCheckIn(p._id)} onClick={() => handleCheckIn(p._id)}
style={{ style={{
...checkInBtn, ...checkInBtn,
opacity: canCheckIn ? 1 : 0.5, opacity: canWriteAttendance && canCheckIn ? 1 : 0.5,
}} }}
> >
<UserCheck size={14} /> <UserCheck size={14} />

View File

@ -0,0 +1,279 @@
import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, Mail, Square, X } from "lucide-react";
import { campaignsApi, type MessagingCampaign } from "../../../services/api/campaigns";
import { PanelShell } from "../panels/PanelShell";
const btnSecondary: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
color: "var(--card-foreground)",
border: "1px solid var(--border)",
cursor: "pointer",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
};
const btnDanger: React.CSSProperties = {
...btnSecondary,
color: "var(--destructive)",
borderColor: "var(--destructive)",
};
const LIST_GRID = "minmax(120px, 1.4fr) 88px 64px 56px 56px minmax(110px, 1fr) minmax(120px, 1fr)";
function formatExecutedAt(c: MessagingCampaign): string {
if (c.sentAt) {
const d = new Date(c.sentAt);
if (!Number.isNaN(d.getTime())) return d.toLocaleString();
}
if (c.status === "scheduled" && c.scheduledFor) {
const d = new Date(c.scheduledFor);
if (!Number.isNaN(d.getTime())) return `Scheduled ${d.toLocaleString()}`;
}
return "—";
}
function statValue(value: number | undefined): string {
return value == null ? "—" : String(value);
}
export function EventEmailCampaignsPanel({
eventId,
eventName,
onClose,
onChanged,
}: {
eventId: string;
eventName?: string;
onClose: () => void;
onChanged?: () => void;
}) {
const [campaigns, setCampaigns] = useState<MessagingCampaign[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [statsCampaign, setStatsCampaign] = useState<MessagingCampaign | null>(null);
const loadCampaigns = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await campaignsApi.listForEvent(eventId);
if (res.error) {
setError(typeof res.error === "string" ? res.error : "Failed to load campaigns.");
setCampaigns([]);
} else {
const all = res.data?.campaigns ?? [];
setCampaigns(all.filter((c) => c.channel === "email"));
}
} catch {
setError("Failed to load campaigns.");
setCampaigns([]);
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void loadCampaigns();
}, [loadCampaigns]);
const notifyChanged = () => {
onChanged?.();
};
const stopCamp = async (id: string) => {
await campaignsApi.stop(id);
await loadCampaigns();
notifyChanged();
};
const cancelSchedule = async (id: string) => {
await campaignsApi.cancel(id);
await loadCampaigns();
notifyChanged();
};
const showStats = async (c: MessagingCampaign) => {
const r = await campaignsApi.analytics(c._id);
if (r.data && typeof r.data === "object" && "stats" in r.data) {
setStatsCampaign(r.data as MessagingCampaign);
}
};
return (
<>
<PanelShell
onClose={onClose}
title="Email campaigns"
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · Email Campaign"}
description="Email campaigns for this event. Stop, unschedule, or view stats."
completedCount={0}
totalSections={1}
ctaLabel=""
showProgress={false}
hidePrimaryCta
panelWidth="min(900px, 100vw)"
>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
{error ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{error}</p>
) : null}
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
overflow: "auto",
}}
>
<div style={{ minWidth: 640 }}>
<div
style={{
display: "grid",
gridTemplateColumns: LIST_GRID,
padding: "8px 16px",
background: "var(--secondary)",
borderBottom: "1px solid var(--border)",
gap: "8px",
}}
>
{["Campaign", "Status", "Queued", "Sent", "Failed", "Executed", "Actions"].map((h) => (
<span
key={h}
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
color: "var(--muted)",
textTransform: "uppercase",
}}
>
{h}
</span>
))}
</div>
{loading ? (
<p style={{ padding: 24, fontSize: "var(--text-sm)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : campaigns.length === 0 ? (
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 8, alignItems: "flex-start" }}>
<Mail size={20} style={{ color: "var(--muted)" }} />
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
No email campaigns for this event yet.
</p>
</div>
) : (
campaigns.map((c) => (
<div
key={c._id}
style={{
display: "grid",
gridTemplateColumns: LIST_GRID,
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
gap: "8px",
}}
>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--foreground)" }}>
{c.name}
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--card-foreground)" }}>{c.status}</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>
{statValue(c.stats?.total)}
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>
{statValue(c.stats?.sent)}
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>
{statValue(c.stats?.failed)}
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>{formatExecutedAt(c)}</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{["sending", "sent", "failed", "stopped"].includes(c.status) && (
<button type="button" onClick={() => void showStats(c)} style={btnSecondary}>
<BarChart2 size={12} /> Stats
</button>
)}
{c.status === "scheduled" && (
<button type="button" onClick={() => void cancelSchedule(c._id)} style={btnSecondary}>
Unschedule
</button>
)}
{c.status !== "stopped" && (
<button type="button" onClick={() => void stopCamp(c._id)} style={btnDanger}>
<Square size={12} /> Stop
</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
</PanelShell>
{statsCampaign && (
<div
role="dialog"
aria-modal
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
zIndex: 400,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
onClick={() => setStatsCampaign(null)}
>
<div
style={{
background: "var(--card)",
borderRadius: "var(--radius-card)",
padding: 24,
maxWidth: 420,
width: "100%",
border: "1px solid var(--border)",
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: "var(--text-lg)" }}>Campaign stats</h2>
<button
type="button"
aria-label="Close"
onClick={() => setStatsCampaign(null)}
style={{ background: "none", border: "none", cursor: "pointer" }}
>
<X size={18} />
</button>
</div>
<p style={{ fontSize: "var(--text-sm)", margin: "0 0 8px" }}>{statsCampaign.name}</p>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px" }}>
Status: {statsCampaign.status}
</p>
<ul style={{ margin: 0, paddingLeft: 18, fontSize: "var(--text-sm)" }}>
<li>Total queued: {statsCampaign.stats?.total ?? "—"}</li>
<li>Sent: {statsCampaign.stats?.sent ?? "—"}</li>
<li>Failed: {statsCampaign.stats?.failed ?? "—"}</li>
</ul>
{statsCampaign.sentAt && (
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginTop: 12 }}>
Sent: {new Date(statsCampaign.sentAt).toLocaleString()}
</p>
)}
</div>
</div>
)}
</>
);
}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Calendar, MapPin, ExternalLink, Pencil } from "lucide-react"; import { Calendar, MapPin, ExternalLink, Pencil, Settings } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import type { Event } from "../../../services/api/types"; import type { Event } from "../../../services/api/types";
import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl"; import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl";
@ -63,11 +63,13 @@ export function EventHeader({
event, event,
loading = false, loading = false,
onEditEvent, onEditEvent,
onWebsiteSettings,
canEdit = true, canEdit = true,
}: { }: {
event: Event | null; event: Event | null;
loading?: boolean; loading?: boolean;
onEditEvent?: () => void; onEditEvent?: () => void;
onWebsiteSettings?: () => void;
canEdit?: boolean; canEdit?: boolean;
}) { }) {
const bp = useBreakpoint(); const bp = useBreakpoint();
@ -165,6 +167,32 @@ export function EventHeader({
<ExternalLink size={12} /> View Website <ExternalLink size={12} /> View Website
</a> </a>
) : null} ) : null}
{!isMobile && event ? (
<button
type="button"
title="Website settings"
aria-label="Website settings"
disabled={!canEdit}
onClick={() => canEdit && onWebsiteSettings?.()}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "30px",
height: "30px",
padding: 0,
borderRadius: "var(--radius-button)",
background: canEdit ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.08)",
backdropFilter: "blur(4px)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.25)",
cursor: canEdit ? "pointer" : "not-allowed",
opacity: canEdit ? 1 : 0.7,
}}
>
<Settings size={14} />
</button>
) : null}
<button <button
type="button" type="button"
disabled={!canEdit || !event} disabled={!canEdit || !event}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react"; import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
@ -9,14 +9,17 @@ import { eventStaffApi } from "../../../services/api/eventStaff";
import { quizPollsApi } from "../../../services/api/quizPolls"; import { quizPollsApi } from "../../../services/api/quizPolls";
import { badgeBatchesApi } from "../../../services/api/badgeBatches"; import { badgeBatchesApi } from "../../../services/api/badgeBatches";
import { eventTicketsApi } from "../../../services/api/eventTickets"; import { eventTicketsApi } from "../../../services/api/eventTickets";
import { campaignsApi } from "../../../services/api/campaigns";
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings"; import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel"; import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
import { CreateFormPanel } from "../panels/CreateFormPanel"; import { CreateFormPanel } from "../panels/CreateFormPanel";
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel"; import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
import { ShareFormPanel } from "../forms/ShareFormPanel"; import { ShareFormPanel } from "../forms/ShareFormPanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel"; import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel"; import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel"; import { EventDocumentsPanel } from "./EventDocumentsPanel";
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal"; import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal"; import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal"; import type { Question } from "../quiz/AddQuestionModal";
@ -201,8 +204,24 @@ export function EventHomeModules() {
const isSuperAdmin = user?.role === "super_admin"; const isSuperAdmin = user?.role === "super_admin";
const canReadForms = canModule("forms", "read"); const canReadForms = canModule("forms", "read");
const canWriteForms = canModule("forms", "write"); const canWriteForms = canModule("forms", "write");
const canReadSessions = canModule("sessions", "read");
const canWriteSessions = canModule("sessions", "write"); const canWriteSessions = canModule("sessions", "write");
const canReadUserMgmt = canModule("user_management", "read");
const canWriteUserMgmt = canModule("user_management", "write"); const canWriteUserMgmt = canModule("user_management", "write");
const canReadActivities =
canModule("live_polls", "read") || canModule("multiple_quiz_modes", "read");
const canWriteActivities =
canModule("live_polls", "write") || canModule("multiple_quiz_modes", "write");
const canReadBadges = canModule("custom_badge_and_print_file_creation", "read");
const canWriteBadges = canModule("custom_badge_and_print_file_creation", "write");
const canReadTicketing = canModule("ticketing_free_and_paid", "read");
const canReadMicrosite = canModule("event_website", "read");
const canReadRewards = canModule("gamification", "read");
const canReadWebCheckIn = canModule("web_check_in", "read");
const canWriteWebCheckIn = canModule("web_check_in", "write");
const canReadDocuments = canModule("content_management", "read");
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
const isTablet = bp === "tablet"; const isTablet = bp === "tablet";
@ -244,6 +263,10 @@ export function EventHomeModules() {
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false); const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
const [webCheckInLoading, setWebCheckInLoading] = useState(false); const [webCheckInLoading, setWebCheckInLoading] = useState(false);
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false); const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = useState(false);
useEffect(() => { useEffect(() => {
if (!selectedEventId) { if (!selectedEventId) {
@ -450,6 +473,28 @@ export function EventHomeModules() {
void loadAgendaMeta(); void loadAgendaMeta();
}, [loadAgendaMeta]); }, [loadAgendaMeta]);
const loadEmailCampaignMeta = useCallback(async () => {
if (!selectedEventId) {
setEmailCampaignCount(0);
setEmailCampaignLoading(false);
return;
}
setEmailCampaignLoading(true);
try {
const res = await campaignsApi.listForEvent(selectedEventId);
const campaigns = res.data?.campaigns ?? [];
setEmailCampaignCount(campaigns.filter((c) => c.channel === "email").length);
} catch {
setEmailCampaignCount(0);
} finally {
setEmailCampaignLoading(false);
}
}, [selectedEventId]);
useEffect(() => {
void loadEmailCampaignMeta();
}, [loadEmailCampaignMeta]);
const handleBadgeSuccess = () => { const handleBadgeSuccess = () => {
setShowAddBadge(false); setShowAddBadge(false);
setDuplicateBadgeBatchId(null); setDuplicateBadgeBatchId(null);
@ -528,6 +573,11 @@ export function EventHomeModules() {
: agendaLoading : agendaLoading
? "Loading…" ? "Loading…"
: `${agendaCounts.tracks} track${agendaCounts.tracks === 1 ? "" : "s"} · ${agendaCounts.sessions} session${agendaCounts.sessions === 1 ? "" : "s"}`; : `${agendaCounts.tracks} track${agendaCounts.tracks === 1 ? "" : "s"} · ${agendaCounts.sessions} session${agendaCounts.sessions === 1 ? "" : "s"}`;
const emailCampaignMeta = !selectedEventId
? "Select an event to manage email campaigns"
: emailCampaignLoading
? "Loading…"
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
const webCheckInMeta = !selectedEventId const webCheckInMeta = !selectedEventId
? "Select an event to configure web check-in" ? "Select an event to configure web check-in"
: webCheckInLoading : webCheckInLoading
@ -584,6 +634,7 @@ export function EventHomeModules() {
gap: "12px", gap: "12px",
}} }}
> >
{canReadSessions ? (
<ModuleCard <ModuleCard
icon={<CalendarDays size={18} />} icon={<CalendarDays size={18} />}
title="Agenda" title="Agenda"
@ -630,6 +681,7 @@ export function EventHomeModules() {
</> </>
} }
/> />
) : null}
{canReadForms ? eventForms.map((form) => { {canReadForms ? eventForms.map((form) => {
const linkCopied = copiedFormLinkId === form._id; const linkCopied = copiedFormLinkId === form._id;
return ( return (
@ -776,6 +828,7 @@ export function EventHomeModules() {
} }
/> />
) : null} ) : null}
{canReadUserMgmt ? (
<ModuleCard <ModuleCard
icon={<ShieldCheck size={18} />} icon={<ShieldCheck size={18} />}
title="Users & Permissions" title="Users & Permissions"
@ -824,6 +877,8 @@ export function EventHomeModules() {
</> </>
} }
/> />
) : null}
{canReadActivities ? (
<ModuleCard <ModuleCard
icon={<BarChart2 size={18} />} icon={<BarChart2 size={18} />}
title="Activities" title="Activities"
@ -832,20 +887,20 @@ export function EventHomeModules() {
<> <>
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled || !canWriteActivities}
style={{ style={{
...moduleButtonStyle(), ...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1, opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer", cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
}} }}
onClick={() => setShowAddPoll(true)} onClick={() => setShowAddPoll(true)}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (actionsDisabled) return; if (actionsDisabled || !canWriteActivities) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (actionsDisabled) return; if (actionsDisabled || !canWriteActivities) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}} }}
@ -854,11 +909,11 @@ export function EventHomeModules() {
</button> </button>
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled || !canWriteActivities}
style={{ style={{
...moduleButtonStyle(true), ...moduleButtonStyle(true),
opacity: actionsDisabled ? 0.55 : 1, opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer", cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
}} }}
onClick={() => setShowAddQuiz(true)} onClick={() => setShowAddQuiz(true)}
> >
@ -867,6 +922,8 @@ export function EventHomeModules() {
</> </>
} }
/> />
) : null}
{canReadBadges ? (
<ModuleCard <ModuleCard
icon={<Tag size={18} />} icon={<Tag size={18} />}
title="Tag & Badge Configuration" title="Tag & Badge Configuration"
@ -895,6 +952,7 @@ export function EventHomeModules() {
> >
View Badges View Badges
</button> </button>
{canWriteBadges ? (
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled}
@ -907,9 +965,12 @@ export function EventHomeModules() {
> >
Add Badge Add Badge
</button> </button>
) : null}
</> </>
} }
/> />
) : null}
{canReadTicketing ? (
<ModuleCard <ModuleCard
icon={<Ticket size={18} />} icon={<Ticket size={18} />}
title="Ticket configuration" title="Ticket configuration"
@ -929,6 +990,8 @@ export function EventHomeModules() {
</button> </button>
} }
/> />
) : null}
{canReadMicrosite ? (
<ModuleCard <ModuleCard
icon={<Globe size={18} />} icon={<Globe size={18} />}
title="Microsite" title="Microsite"
@ -948,6 +1011,8 @@ export function EventHomeModules() {
</button> </button>
} }
/> />
) : null}
{canReadRewards ? (
<ModuleCard <ModuleCard
icon={<Trophy size={18} />} icon={<Trophy size={18} />}
title="Reward Points Config" title="Reward Points Config"
@ -967,6 +1032,8 @@ export function EventHomeModules() {
</button> </button>
} }
/> />
) : null}
{canReadWebCheckIn ? (
<ModuleCard <ModuleCard
icon={<Plus size={18} />} icon={<Plus size={18} />}
title="Web check-in" title="Web check-in"
@ -976,12 +1043,12 @@ export function EventHomeModules() {
webCheckInEnabled ? ( webCheckInEnabled ? (
<button <button
type="button" type="button"
disabled={actionsDisabled || webCheckInLoading} disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
style={{ style={{
...moduleButtonStyle(), ...moduleButtonStyle(),
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1, opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
cursor: cursor:
actionsDisabled || webCheckInLoading actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
? "not-allowed" ? "not-allowed"
: "pointer", : "pointer",
}} }}
@ -992,12 +1059,12 @@ export function EventHomeModules() {
) : ( ) : (
<button <button
type="button" type="button"
disabled={actionsDisabled || webCheckInLoading} disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
style={{ style={{
...moduleButtonStyle(true), ...moduleButtonStyle(true),
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1, opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
cursor: cursor:
actionsDisabled || webCheckInLoading actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
? "not-allowed" ? "not-allowed"
: "pointer", : "pointer",
}} }}
@ -1008,6 +1075,8 @@ export function EventHomeModules() {
) )
} }
/> />
) : null}
{canReadDocuments ? (
<ModuleCard <ModuleCard
icon={<Upload size={18} />} icon={<Upload size={18} />}
title="Upload Document" title="Upload Document"
@ -1027,6 +1096,52 @@ export function EventHomeModules() {
</button> </button>
} }
/> />
) : null}
{canReadEmailCampaigns ? (
<ModuleCard
icon={<Mail size={18} />}
title="Email Campaign"
meta={emailCampaignMeta}
actions={
<>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setShowEmailCampaignsPanel(true)}
onMouseEnter={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}}
>
Manage
</button>
<button
type="button"
disabled={actionsDisabled || !canWriteEmailCampaigns}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || !canWriteEmailCampaigns ? 0.55 : 1,
cursor: actionsDisabled || !canWriteEmailCampaigns ? "not-allowed" : "pointer",
}}
onClick={() => setShowCreateEmailCampaign(true)}
>
Create
</button>
</>
}
/>
) : null}
</section> </section>
{formShareTarget ? ( {formShareTarget ? (
@ -1127,6 +1242,23 @@ export function EventHomeModules() {
onClose={() => setShowDocumentsPanel(false)} onClose={() => setShowDocumentsPanel(false)}
/> />
) : null} ) : null}
{showEmailCampaignsPanel && selectedEventId ? (
<EventEmailCampaignsPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
onClose={() => setShowEmailCampaignsPanel(false)}
onChanged={() => void loadEmailCampaignMeta()}
/>
) : null}
{showCreateEmailCampaign && selectedEventId ? (
<CreateEmailCampaignPanel
eventId={selectedEventId}
onClose={() => setShowCreateEmailCampaign(false)}
onSuccess={() => void loadEmailCampaignMeta()}
/>
) : null}
</> </>
); );
} }

View File

@ -3,7 +3,7 @@ import {
Palette, Mic2, Users, FileText, BookOpen, MessageSquare, Palette, Mic2, Users, FileText, BookOpen, MessageSquare,
Shield, Image, Layout, Tag, Mail, Shield, Image, Layout, Tag, Mail,
Plus, Search, X, MoreHorizontal, Upload, Plus, Search, X, MoreHorizontal, Upload,
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, RotateCcw,
Link2, List, Check, Share2, Link2, List, Check, Share2,
} from "lucide-react"; } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
@ -46,6 +46,7 @@ import type {
EventMicrosite, EventMicrosite,
} from "../../../services/api"; } from "../../../services/api";
import { LoadingState } from "../shared"; import { LoadingState } from "../shared";
import { buildEmailerPreviewHtml } from "../../utils/emailerPreview";
import { ConfirmModal } from "../shared"; import { ConfirmModal } from "../shared";
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel"; import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel"; import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
@ -147,6 +148,7 @@ function formatLastEdited(updatedAt?: string): string {
export function DesignPage({ subPage }: { subPage?: string }) { export function DesignPage({ subPage }: { subPage?: string }) {
const { user } = useAuth(); const { user } = useAuth();
const { canModule } = usePermission();
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
const activeTab = subPage === "design-brochure" ? "Brochure" const activeTab = subPage === "design-brochure" ? "Brochure"
@ -156,6 +158,8 @@ export function DesignPage({ subPage }: { subPage?: string }) {
: "Brochure"; : "Brochure";
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure"; const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
const isSuperAdmin = user?.role === "super_admin"; const isSuperAdmin = user?.role === "super_admin";
const canWriteEmailers = isSuperAdmin || canModule("email_outreach_campaigns", "write");
const canManageEmailers = apiType === "emailer" ? canWriteEmailers : isSuperAdmin;
const canManageMicrosite = const canManageMicrosite =
apiType === "microsite" && apiType === "microsite" &&
user && user &&
@ -371,6 +375,9 @@ export function DesignPage({ subPage }: { subPage?: string }) {
if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") { if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") {
html = String((c as { html: string }).html || ""); html = String((c as { html: string }).html || "");
} }
if (!html.trim() && c != null && typeof c === "object") {
html = buildEmailerPreviewHtml(c as Record<string, unknown>);
}
if (!html.trim()) { if (!html.trim()) {
html = html =
'<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>'; '<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>';
@ -462,7 +469,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
<Upload size={13} /> Import <Upload size={13} /> Import
</button> </button>
)} )}
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && ( {(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : canManageEmailers) && (
<button <button
onClick={handleCreate} onClick={handleCreate}
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
@ -507,7 +514,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
{templateIcons[activeTab]} {templateIcons[activeTab]}
{hovered === t._id && ( {hovered === t._id && (
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}> <div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
{isSuperAdmin && ( {(isSuperAdmin || (apiType === "emailer" && canWriteEmailers)) && (
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button> <button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button>
)} )}
{apiType === "brochure" ? ( {apiType === "brochure" ? (
@ -552,7 +559,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
{emailerPreviewBusyId === t._id ? "…" : "Preview"} {emailerPreviewBusyId === t._id ? "…" : "Preview"}
</button> </button>
) : null} ) : null}
{isSuperAdmin && ( {((isSuperAdmin && apiType !== "emailer") || (apiType === "emailer" && canWriteEmailers && t.canDelete !== false && !t.isSystemTemplate)) && (
<button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button> <button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button>
)} )}
</div> </div>
@ -561,13 +568,21 @@ export function DesignPage({ subPage }: { subPage?: string }) {
<div style={{ padding: "10px 12px" }}> <div style={{ padding: "10px 12px" }}>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p> <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span> <span style={{ display: "inline-flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
{apiType === "emailer" && t.isSystemTemplate ? (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--primary)", background: "var(--accent)", padding: "1px 6px", borderRadius: "3px" }}>Global</span>
) : null}
{apiType === "emailer" && t.isOrgOverride ? (
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>Customized</span>
) : null}
</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span> <span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span>
</div> </div>
</div> </div>
</div> </div>
))} ))}
{isSuperAdmin && ( {canManageEmailers && (
<div <div
onClick={handleCreate} onClick={handleCreate}
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }} style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
@ -1070,7 +1085,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
? "Checked In" ? "Checked In"
: statusRaw === "confirmed" : statusRaw === "confirmed"
? "Confirmed" ? "Confirmed"
: "Pending"; : statusRaw === "rejected"
? "Rejected"
: "Pending";
const ev = p.event as { _id?: string; name?: string } | undefined; const ev = p.event as { _id?: string; name?: string } | undefined;
const eventName = ev?.name ?? "—"; const eventName = ev?.name ?? "—";
const id = String(p._id); const id = String(p._id);
@ -1079,6 +1096,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
export function AllParticipantsPage() { export function AllParticipantsPage() {
const { user } = useAuth(); const { user } = useAuth();
const { canModule } = usePermission();
const canWriteParticipants = canModule("participants", "write");
const canDeleteParticipants = canModule("participants", "delete");
const listOrgId = portfolioListOrganizationId(user); const listOrgId = portfolioListOrganizationId(user);
const isSuperAdmin = user?.role === "super_admin"; const isSuperAdmin = user?.role === "super_admin";
const { setActiveNav } = useAppNav(); const { setActiveNav } = useAppNav();
@ -1089,6 +1109,7 @@ export function AllParticipantsPage() {
const [searchInput, setSearchInput] = useState(""); const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [activeTag, setActiveTag] = useState<string | null>(null); const [activeTag, setActiveTag] = useState<string | null>(null);
const [showDeleted, setShowDeleted] = useState(false);
const [hovered, setHovered] = useState<string | null>(null); const [hovered, setHovered] = useState<string | null>(null);
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -1100,6 +1121,7 @@ export function AllParticipantsPage() {
const [tagOptions, setTagOptions] = useState<string[]>([]); const [tagOptions, setTagOptions] = useState<string[]>([]);
const [editParticipantId, setEditParticipantId] = useState<string | null>(null); const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const { const {
@ -1131,17 +1153,18 @@ export function AllParticipantsPage() {
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); }; return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchInput]); }, [searchInput]);
useEffect(() => { setPage(1); }, [search, activeTag]); useEffect(() => { setPage(1); }, [search, activeTag, showDeleted]);
useEffect(() => { useEffect(() => {
if (!listOrgId) { if (!listOrgId) {
setTagOptions([]); setTagOptions([]);
return; return;
} }
if (showDeleted) return;
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => { participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
if (r.data?.tags?.length) setTagOptions(r.data.tags); if (r.data?.tags?.length) setTagOptions(r.data.tags);
}); });
}, [listOrgId, listNonce]); }, [listOrgId, listNonce, showDeleted]);
useEffect(() => { useEffect(() => {
if (!listOrgId) { if (!listOrgId) {
@ -1157,7 +1180,8 @@ export function AllParticipantsPage() {
page, page,
limit, limit,
q: search || undefined, q: search || undefined,
tag: activeTag ?? undefined, tag: showDeleted ? undefined : (activeTag ?? undefined),
deleted: showDeleted || undefined,
}) })
.then((res) => { .then((res) => {
setLoading(false); setLoading(false);
@ -1167,7 +1191,7 @@ export function AllParticipantsPage() {
} }
}) })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, [listOrgId, page, limit, search, activeTag, listNonce]); }, [listOrgId, page, limit, search, activeTag, listNonce, showDeleted]);
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); }; const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
@ -1214,12 +1238,24 @@ export function AllParticipantsPage() {
setImportMsg(res.error); setImportMsg(res.error);
return; return;
} }
setImportMsg(`Removed ${name}.`); setImportMsg(`Moved ${name} to Deleted.`);
setSelectAllMatchingActive(false); setSelectAllMatchingActive(false);
setSelectedIds((prev) => prev.filter((x) => x !== id)); setSelectedIds((prev) => prev.filter((x) => x !== id));
setListNonce((n) => n + 1); setListNonce((n) => n + 1);
}; };
const restoreOne = async (id: string, name: string) => {
setRestoringId(id);
const res = await participantsApi.restore(id);
setRestoringId(null);
if (res.error) {
setImportMsg(res.error);
return;
}
setImportMsg(`Restored ${name}.`);
setListNonce((n) => n + 1);
};
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{importMsg && ( {importMsg && (
@ -1233,25 +1269,57 @@ export function AllParticipantsPage() {
<p style={labelStyle}>Portfolio</p> <p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1> <h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}> <p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))} {total} {showDeleted ? "deleted" : "active"} in your organization · Page {page} of {Math.max(1, Math.ceil(total / limit))}
</p> </p>
</div> </div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
<button type="button" disabled={exporting} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting ? 0.7 : 1 }}> <button type="button" disabled={exporting || showDeleted} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting || showDeleted ? 0.7 : 1 }}>
<Download size={13} /> {exporting ? "Exporting…" : "Export"} <Download size={13} /> {exporting ? "Exporting…" : "Export"}
</button> </button>
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}> {canWriteParticipants && !showDeleted && (
<Upload size={13} /> Import <button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
</button> <Upload size={13} /> Import
</button>
)}
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}> <button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
Custom lists Custom lists
</button> </button>
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}> {canWriteParticipants && (
<Plus size={13} /> Create Custom List <button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
</button> <Plus size={13} /> Create Custom List
</button>
)}
</div> </div>
</div> </div>
{/* Active / Deleted toggle */}
<div style={{ display: "inline-flex", alignItems: "center", gap: "0", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", overflow: "hidden", alignSelf: "flex-start" }}>
<button
type="button"
onClick={() => { setShowDeleted(false); clearSelection(); }}
style={{
padding: "6px 14px", border: "none", cursor: "pointer",
background: !showDeleted ? "var(--primary)" : "var(--card)",
color: !showDeleted ? "var(--primary-foreground)" : "var(--card-foreground)",
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)",
}}
>
Active
</button>
<button
type="button"
onClick={() => { setShowDeleted(true); clearSelection(); setActiveTag(null); }}
style={{
padding: "6px 14px", border: "none", cursor: "pointer",
background: showDeleted ? "var(--primary)" : "var(--card)",
color: showDeleted ? "var(--primary-foreground)" : "var(--card-foreground)",
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)",
}}
>
Deleted
</button>
</div>
{/* Filter row */} {/* Filter row */}
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<ParticipantSelectAllMatching <ParticipantSelectAllMatching
@ -1280,23 +1348,29 @@ export function AllParticipantsPage() {
)} )}
</div> </div>
{!showDeleted && (
<ParticipantBulkActionBar <ParticipantBulkActionBar
selectedCount={selectedIds.length} selectedCount={selectedIds.length}
busy={bulk.bulkBusy} busy={bulk.bulkBusy}
variant="portfolio" variant="portfolio"
onAddTags={() => void bulk.openBulkDialog("addTags")} onAddTags={canWriteParticipants ? () => void bulk.openBulkDialog("addTags") : undefined}
onRemoveTags={() => void bulk.openBulkDialog("removeTags")} onRemoveTags={canWriteParticipants ? () => void bulk.openBulkDialog("removeTags") : undefined}
onAssignEvents={() => void bulk.openBulkDialog("assignEvents")} onAssignEvents={canWriteParticipants ? () => void bulk.openBulkDialog("assignEvents") : undefined}
onNewList={() => void bulk.openBulkDialog("newList")} onNewList={canWriteParticipants ? () => void bulk.openBulkDialog("newList") : undefined}
onAddToList={() => void bulk.openBulkDialog("addToList")} onAddToList={canWriteParticipants ? () => void bulk.openBulkDialog("addToList") : undefined}
onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }} onDeleteSelected={
canDeleteParticipants
? () => { if (window.confirm(`Move ${selectedIds.length} participant(s) to Deleted?`)) void bulk.runBulkDelete(); }
: undefined
}
onClearSelection={clearSelection} onClearSelection={clearSelection}
/> />
)}
{/* Table */} {/* Table */}
<ScrollTable minWidth={720}> <ScrollTable minWidth={720}>
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}> <div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} /> <input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} disabled={showDeleted} style={{ width: "16px", height: "16px", cursor: showDeleted ? "not-allowed" : "pointer", opacity: showDeleted ? 0.4 : 1 }} />
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span> <span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span> <span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span> <span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span>
@ -1309,10 +1383,12 @@ export function AllParticipantsPage() {
<div style={{ padding: "40px", textAlign: "center" }}> <div style={{ padding: "40px", textAlign: "center" }}>
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} /> <Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}> <p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
No participants found {showDeleted ? "No deleted participants" : "No participants found"}
</p> </p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}> <p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
{(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."} {showDeleted
? "Soft-deleted people will appear here so you can restore them."
: (search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
</p> </p>
{(search || activeTag) && ( {(search || activeTag) && (
<button type="button" onClick={clearFilters} <button type="button" onClick={clearFilters}
@ -1327,14 +1403,14 @@ export function AllParticipantsPage() {
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }} style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
> >
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} style={{ width: "16px", height: "16px", cursor: "pointer" }} /> <input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} disabled={showDeleted} style={{ width: "16px", height: "16px", cursor: showDeleted ? "not-allowed" : "pointer", opacity: showDeleted ? 0.4 : 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}> <div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}> <div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span> <span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
</div> </div>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p> <p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" ? "var(--muted)" : "#15803d" }}>{p.status}</p> <p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" || p.status === "Rejected" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
</div> </div>
</div> </div>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span> <span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span>
@ -1347,12 +1423,32 @@ export function AllParticipantsPage() {
</div> </div>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span> <span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}> <div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}> {showDeleted ? (
<Pencil size={13} /> canDeleteParticipants ? (
</button> <button
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}> type="button"
<Trash2 size={13} /> title="Restore participant"
</button> disabled={restoringId === p.id}
onClick={() => void restoreOne(p.id, p.name)}
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "4px", height: "30px", padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid #86efac", background: "#dcfce7", color: "#15803d", cursor: restoringId === p.id ? "wait" : "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}
>
<RotateCcw size={12} /> {restoringId === p.id ? "…" : "Restore"}
</button>
) : null
) : (
<>
{canWriteParticipants && (
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
<Pencil size={13} />
</button>
)}
{canDeleteParticipants && (
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
<Trash2 size={13} />
</button>
)}
</>
)}
</div> </div>
</div> </div>
))} ))}
@ -1420,7 +1516,7 @@ export function AllParticipantsPage() {
{deleteTarget && ( {deleteTarget && (
<ConfirmModal <ConfirmModal
title="Delete participant?" title="Delete participant?"
message={`Remove ${deleteTarget.name} from your organization? This cannot be undone.`} message={`Move ${deleteTarget.name} to Deleted? You can restore them later from the Deleted tab.`}
confirmLabel="Delete" confirmLabel="Delete"
onConfirm={() => void confirmDeleteOne()} onConfirm={() => void confirmDeleteOne()}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
@ -1435,6 +1531,7 @@ export function AllParticipantsPage() {
onToggleTag={bulk.toggleSelectedTag} onToggleTag={bulk.toggleSelectedTag}
availableTags={tagOptions} availableTags={tagOptions}
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))} removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
tagScope="global"
newListName={bulk.newListName} newListName={bulk.newListName}
onNewListNameChange={bulk.setNewListName} onNewListNameChange={bulk.setNewListName}
newListDesc={bulk.newListDesc} newListDesc={bulk.newListDesc}
@ -1478,6 +1575,7 @@ function formatListMemberStatus(s: string | undefined): string {
if (raw === "checked_in") return "Checked In"; if (raw === "checked_in") return "Checked In";
if (raw === "confirmed") return "Confirmed"; if (raw === "confirmed") return "Confirmed";
if (raw === "pending") return "Pending"; if (raw === "pending") return "Pending";
if (raw === "rejected") return "Rejected";
return String(s); return String(s);
} }

View File

@ -1392,15 +1392,40 @@ function EmailDeliverySection() {
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>} {err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>}
{msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>} {msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>}
<ToggleRow value={enabled} onChange={setEnabled} label="Use custom SMTP" /> <ToggleRow value={enabled} onChange={setEnabled} label="Use custom SMTP" />
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, lineHeight: 1.45 }}>
Microsoft 365 / Outlook: host <code>smtp.office365.com</code>, port <code>587</code>, SSL off.
Username = full mailbox email. Authenticated SMTP must be enabled for that user; with MFA use an App Password.
Save settings, then re-enter the password if login still fails.
</p>
<Field> <Field>
<Label text="SMTP host" /> <Label text="SMTP host" />
<input style={IS} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" onFocus={focusIn} onBlur={focusOut} /> <input style={IS} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.office365.com" onFocus={focusIn} onBlur={focusOut} />
</Field> </Field>
<Field> <Field>
<Label text="Port" /> <Label text="Port" />
<input style={IS} type="number" value={port} onChange={(e) => setPort(parseInt(e.target.value, 10) || 587)} onFocus={focusIn} onBlur={focusOut} /> <input
style={IS}
type="number"
value={port}
onChange={(e) => {
const next = parseInt(e.target.value, 10) || 587;
setPort(next);
// Match common provider defaults so SSL mode matches the port.
if (next === 465) setSecure(true);
else if (next === 587 || next === 25 || next === 2525) setSecure(false);
}}
onFocus={focusIn}
onBlur={focusOut}
/>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
Use 587 with STARTTLS (SSL off), or 465 with SSL/TLS on. Mixing these causes “wrong version number” errors.
</p>
</Field> </Field>
<ToggleRow value={secure} onChange={setSecure} label="Use SSL/TLS (e.g. port 465)" /> <ToggleRow
value={secure}
onChange={setSecure}
label="Use SSL/TLS (port 465 only — leave off for 587)"
/>
<Field> <Field>
<Label text="Username" /> <Label text="Username" />
<input style={IS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} onFocus={focusIn} onBlur={focusOut} /> <input style={IS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} onFocus={focusIn} onBlur={focusOut} />

View File

@ -10,25 +10,14 @@ import {
type Event as ApiEvent, type Event as ApiEvent,
type ParticipantListRecord, type ParticipantListRecord,
type DesignTemplate, type DesignTemplate,
type CampaignRecipients,
} from "../../../services/api"; } from "../../../services/api";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { RichEmailEditor } from "./RichEmailEditor"; import { RichEmailEditor } from "./RichEmailEditor";
import { PanelShell } from "../panels/PanelShell"; import { PanelShell, RadioGroup, SelectWrap, IS, SS, focusIn, focusOut } from "../panels/PanelShell";
import { EMAIL_PLACEHOLDERS } from "./mergePlaceholders"; import { EMAIL_PLACEHOLDERS } from "./mergePlaceholders";
import { PARTICIPANT_STATUS, type ParticipantStatus } from "../../../config/constants";
const IS: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--input-background)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
outline: "none",
boxSizing: "border-box",
};
function eventLabel(c: MessagingCampaign): string { function eventLabel(c: MessagingCampaign): string {
const e = c.event; const e = c.event;
@ -36,6 +25,36 @@ function eventLabel(c: MessagingCampaign): string {
return "—"; return "—";
} }
const STATUS_LABELS: Record<ParticipantStatus, string> = {
pending: "Pending",
confirmed: "Confirmed",
checked_in: "Checked In",
cancelled: "Cancelled",
rejected: "Rejected",
};
type RecipientSource = "participant_list" | "participants";
function buildRecipients(
source: RecipientSource,
listId: string,
status: "" | ParticipantStatus,
): CampaignRecipients | { error: string } {
if (source === "participant_list") {
if (!listId) return { error: "Select a contact list." };
return { type: "participant_list", participantList: listId };
}
if (!status) return { type: "all" };
return { type: "filtered", filters: { status } };
}
function readFilterStatus(filters: CampaignRecipients["filters"]): "" | ParticipantStatus {
if (!filters || typeof filters !== "object") return "";
const raw = (filters as Record<string, unknown>).status;
if (typeof raw !== "string") return "";
return (PARTICIPANT_STATUS as readonly string[]).includes(raw) ? (raw as ParticipantStatus) : "";
}
export function MessagingEmailPage() { export function MessagingEmailPage() {
const { user } = useAuth(); const { user } = useAuth();
const { setActiveNav, setSettingsInitialSection } = useAppNav(); const { setActiveNav, setSettingsInitialSection } = useAppNav();
@ -51,7 +70,9 @@ export function MessagingEmailPage() {
const [formEventId, setFormEventId] = useState(""); const [formEventId, setFormEventId] = useState("");
const [formName, setFormName] = useState(""); const [formName, setFormName] = useState("");
const [recipientSource, setRecipientSource] = useState<RecipientSource>("participant_list");
const [formListId, setFormListId] = useState(""); const [formListId, setFormListId] = useState("");
const [formStatus, setFormStatus] = useState<"" | ParticipantStatus>("");
const [msgMode, setMsgMode] = useState<"manual" | "template">("template"); const [msgMode, setMsgMode] = useState<"manual" | "template">("template");
const [formTemplateId, setFormTemplateId] = useState(""); const [formTemplateId, setFormTemplateId] = useState("");
const [formSubject, setFormSubject] = useState(""); const [formSubject, setFormSubject] = useState("");
@ -98,7 +119,9 @@ export function MessagingEmailPage() {
setEditing(null); setEditing(null);
setFormEventId(events[0]?._id || ""); setFormEventId(events[0]?._id || "");
setFormName(""); setFormName("");
setRecipientSource("participant_list");
setFormListId(lists[0]?._id || ""); setFormListId(lists[0]?._id || "");
setFormStatus("");
setMsgMode("template"); setMsgMode("template");
setFormTemplateId(templates[0]?._id || ""); setFormTemplateId(templates[0]?._id || "");
setFormSubject(""); setFormSubject("");
@ -114,7 +137,16 @@ export function MessagingEmailPage() {
const eid = typeof c.event === "string" ? c.event : c.event?._id; const eid = typeof c.event === "string" ? c.event : c.event?._id;
setFormEventId(eid || ""); setFormEventId(eid || "");
setFormName(c.name); setFormName(c.name);
setFormListId(c.recipients?.participantList || ""); const recType = c.recipients?.type;
if (recType === "all" || recType === "filtered") {
setRecipientSource("participants");
setFormListId(lists[0]?._id || "");
setFormStatus(recType === "filtered" ? readFilterStatus(c.recipients?.filters) : "");
} else {
setRecipientSource("participant_list");
setFormListId(c.recipients?.participantList || lists[0]?._id || "");
setFormStatus("");
}
const hasTpl = Boolean(c.content?.template); const hasTpl = Boolean(c.content?.template);
setMsgMode(hasTpl ? "template" : "manual"); setMsgMode(hasTpl ? "template" : "manual");
setFormTemplateId(c.content?.template || ""); setFormTemplateId(c.content?.template || "");
@ -125,15 +157,22 @@ export function MessagingEmailPage() {
setPanelOpen(true); setPanelOpen(true);
}; };
const resolveRecipients = (): CampaignRecipients | null => {
const built = buildRecipients(recipientSource, formListId, formStatus);
if ("error" in built) {
setErr(built.error);
return null;
}
return built;
};
const saveDraft = async (): Promise<string | null> => { const saveDraft = async (): Promise<string | null> => {
if (!formEventId || !formName.trim()) { if (!formEventId || !formName.trim()) {
setErr("Event and campaign name are required."); setErr("Event and campaign name are required.");
return null; return null;
} }
if (!formListId) { const recipients = resolveRecipients();
setErr("Select a contact list."); if (!recipients) return null;
return null;
}
setSaving(true); setSaving(true);
setErr(""); setErr("");
if (msgMode === "template" && !formTemplateId) { if (msgMode === "template" && !formTemplateId) {
@ -149,7 +188,6 @@ export function MessagingEmailPage() {
return null; return null;
} }
} }
const recipients = { type: "participant_list" as const, participantList: formListId };
const content = const content =
msgMode === "template" msgMode === "template"
? { subject: "", body: "", template: formTemplateId } ? { subject: "", body: "", template: formTemplateId }
@ -196,10 +234,8 @@ export function MessagingEmailPage() {
setErr("Event and campaign name are required."); setErr("Event and campaign name are required.");
return; return;
} }
if (!formListId) { const recipients = resolveRecipients();
setErr("Select a contact list."); if (!recipients) return;
return;
}
if (msgMode === "template" && !formTemplateId) { if (msgMode === "template" && !formTemplateId) {
setErr("Select an emailer template."); setErr("Select an emailer template.");
return; return;
@ -213,7 +249,6 @@ export function MessagingEmailPage() {
} }
setSaving(true); setSaving(true);
setErr(""); setErr("");
const recipients = { type: "participant_list" as const, participantList: formListId };
const content = const content =
msgMode === "template" msgMode === "template"
? { subject: "", body: "", template: formTemplateId } ? { subject: "", body: "", template: formTemplateId }
@ -466,31 +501,92 @@ export function MessagingEmailPage() {
{!editing && ( {!editing && (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}> <label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Event</span> <span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Event</span>
<select style={IS} value={formEventId} onChange={(e) => setFormEventId(e.target.value)}> <SelectWrap>
<option value="">Select…</option> <select
{events.map((ev) => ( style={SS}
<option key={ev._id} value={ev._id}> value={formEventId}
{ev.name} onChange={(e) => setFormEventId(e.target.value)}
</option> onFocus={focusIn}
))} onBlur={focusOut}
</select> >
<option value="">Select…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
</label> </label>
)} )}
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}> <label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Campaign name</span> <span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Campaign name</span>
<input style={IS} value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="e.g. Registration reminder" /> <input
</label> style={IS}
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}> value={formName}
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span> onChange={(e) => setFormName(e.target.value)}
<select style={IS} value={formListId} onChange={(e) => setFormListId(e.target.value)}> onFocus={focusIn}
<option value="">Select…</option> onBlur={focusOut}
{lists.map((l) => ( placeholder="e.g. Registration reminder"
<option key={l._id} value={l._id}> />
{l.name}
</option>
))}
</select>
</label> </label>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Recipients</span>
<RadioGroup
name="messaging-email-campaign-recipients"
value={recipientSource}
onChange={(v) => setRecipientSource(v as RecipientSource)}
options={[
{ label: "Contact list", value: "participant_list" },
{ label: "All participants", value: "participants" },
]}
/>
</div>
{recipientSource === "participant_list" ? (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
<SelectWrap>
<select
style={SS}
value={formListId}
onChange={(e) => setFormListId(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select…</option>
{lists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
</SelectWrap>
</label>
) : (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Participant status</span>
<SelectWrap>
<select
style={SS}
value={formStatus}
onChange={(e) => setFormStatus(e.target.value as "" | ParticipantStatus)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">All statuses</option>
{PARTICIPANT_STATUS.map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</SelectWrap>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
Sends to participants registered for the selected event
{formStatus ? ` with status ${STATUS_LABELS[formStatus]}` : ""}.
</p>
</label>
)}
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button type="button" onClick={() => setMsgMode("template")} style={msgMode === "template" ? btnPrimary : btnSecondary}> <button type="button" onClick={() => setMsgMode("template")} style={msgMode === "template" ? btnPrimary : btnSecondary}>
@ -504,14 +600,22 @@ export function MessagingEmailPage() {
{msgMode === "template" && ( {msgMode === "template" && (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}> <label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span> <span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span>
<select style={IS} value={formTemplateId} onChange={(e) => setFormTemplateId(e.target.value)}> <SelectWrap>
<option value="">Select…</option> <select
{templates.map((t) => ( style={SS}
<option key={t._id} value={t._id}> value={formTemplateId}
{t.name} onChange={(e) => setFormTemplateId(e.target.value)}
</option> onFocus={focusIn}
))} onBlur={focusOut}
</select> >
<option value="">Select…</option>
{templates.map((t) => (
<option key={t._id} value={t._id}>
{t.name}
</option>
))}
</select>
</SelectWrap>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}> <p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
Subject and body come from this template. The email subject defaults to your campaign name if the template does not set one. Subject and body come from this template. The email subject defaults to your campaign name if the template does not set one.
</p> </p>
@ -522,7 +626,14 @@ export function MessagingEmailPage() {
<> <>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}> <label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Subject</span> <span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Subject</span>
<input style={IS} value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Subject line (placeholders ok)" /> <input
style={IS}
value={formSubject}
onChange={(e) => setFormSubject(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
placeholder="Subject line (placeholders ok)"
/>
</label> </label>
<div> <div>
@ -541,7 +652,14 @@ export function MessagingEmailPage() {
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}> <label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Schedule (optional)</span> <span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Schedule (optional)</span>
<input type="datetime-local" style={IS} value={scheduleLocal} onChange={(e) => setScheduleLocal(e.target.value)} /> <input
type="datetime-local"
style={IS}
value={scheduleLocal}
onChange={(e) => setScheduleLocal(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</label> </label>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>

View File

@ -11,6 +11,7 @@ export const EMAIL_PLACEHOLDERS: { token: string; label: string }[] = [
{ token: "{{event.endDate}}", label: "Event end" }, { token: "{{event.endDate}}", label: "Event end" },
{ token: "{{event.venue}}", label: "Venue" }, { token: "{{event.venue}}", label: "Venue" },
{ token: "{{event.virtualLink}}", label: "Virtual link" }, { token: "{{event.virtualLink}}", label: "Virtual link" },
{ token: "{{event.logoUrl}}", label: "Event logo URL" },
]; ];
/** Dropdown options: map each Meta `{{n}}` to a contact/event field for sending (samples still required for Meta review). */ /** Dropdown options: map each Meta `{{n}}` to a contact/event field for sending (samples still required for Meta review). */

View File

@ -9,16 +9,27 @@ import {
HelperText, HelperText,
} from '../panels/PanelShell'; } from '../panels/PanelShell';
import { import {
eventsApi,
formsApi,
sessionsApi, sessionsApi,
staticMicrositeApi, staticMicrositeApi,
staticMicrositePublicUrl, staticMicrositePublicUrl,
type Event,
type EventStaticMicrosite, type EventStaticMicrosite,
type FormWithSubmissionCount,
type MicrositeV2Template, type MicrositeV2Template,
type MicrositeV2TemplateSchema,
type StaticMicrositeConfig, type StaticMicrositeConfig,
} from '../../../services/api'; } from '../../../services/api';
import { TemplatePicker } from './TemplatePicker'; import { TemplatePicker } from './TemplatePicker';
import { MicrositeConfigForm } from './MicrositeConfigForm'; import { MicrositeConfigForm } from './MicrositeConfigForm';
import { mergeConfig } from './configUtils'; import { mergeConfig } from './configUtils';
import {
applyEventDetailsToMicrositeConfig,
buildEventFeedbackUrl,
buildEventRegistrationUrl,
findContactFormId,
} from './eventMicrositeAutofill';
import { mergeScheduleDays, type ScheduleDay } from './scheduleSync'; import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
const SCHEDULE_SECTION_ID = 'event_schedule'; const SCHEDULE_SECTION_ID = 'event_schedule';
@ -53,19 +64,40 @@ export function ManageStaticMicrositePanel({
const [scheduleSyncing, setScheduleSyncing] = useState(false); const [scheduleSyncing, setScheduleSyncing] = useState(false);
const scheduleAutoFetchedRef = useRef(false); const scheduleAutoFetchedRef = useRef(false);
const configureOpenedRef = useRef(false); const configureOpenedRef = useRef(false);
const [eventDetails, setEventDetails] = useState<Event | null>(null);
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
const withEventAutofill = useCallback(
(cfg: StaticMicrositeConfig, sch: MicrositeV2TemplateSchema, event: Event | null, forms: FormWithSubmissionCount[]) => {
if (!event) return cfg;
return applyEventDetailsToMicrositeConfig(cfg, event, sch, {
registrationUrl: buildEventRegistrationUrl(event, forms),
feedbackUrl: buildEventFeedbackUrl(event, forms),
contactFormId: findContactFormId(forms),
});
},
[],
);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [tplRes, siteRes] = await Promise.all([ const [tplRes, siteRes, eventRes, formsRes] = await Promise.all([
staticMicrositeApi.listTemplates(eventId), staticMicrositeApi.listTemplates(eventId),
staticMicrositeApi.getForEvent(eventId), staticMicrositeApi.getForEvent(eventId),
eventsApi.get(eventId),
formsApi.listForEvent(eventId).catch(() => [] as FormWithSubmissionCount[]),
]); ]);
if (tplRes.error) throw new Error(tplRes.error); if (tplRes.error) throw new Error(tplRes.error);
setTemplates(tplRes.data || []); setTemplates(tplRes.data || []);
const event = !eventRes.error && eventRes.data ? eventRes.data : null;
const forms = formsRes || [];
setEventDetails(event);
setEventForms(forms);
if (!siteRes.error && siteRes.data) { if (!siteRes.error && siteRes.data) {
const site = siteRes.data; const site = siteRes.data;
setExisting(site); setExisting(site);
@ -86,7 +118,7 @@ export function ManageStaticMicrositePanel({
const sch = detail.data?.schema; const sch = detail.data?.schema;
if (!sch) throw new Error('Template configuration schema could not be loaded'); if (!sch) throw new Error('Template configuration schema could not be loaded');
setSchema(sch); setSchema(sch);
setConfig(mergeConfig(sch, site.config)); setConfig(withEventAutofill(mergeConfig(sch, site.config), sch, event, forms));
} finally { } finally {
setSchemaLoading(false); setSchemaLoading(false);
} }
@ -97,7 +129,7 @@ export function ManageStaticMicrositePanel({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [eventId]); }, [eventId, withEventAutofill]);
useEffect(() => { useEffect(() => {
void load(); void load();
@ -138,7 +170,7 @@ export function ManageStaticMicrositePanel({
const sch = res.data?.schema; const sch = res.data?.schema;
if (!sch) throw new Error('Template configuration schema could not be loaded'); if (!sch) throw new Error('Template configuration schema could not be loaded');
setSchema(sch); setSchema(sch);
setConfig(mergeConfig(sch, config)); setConfig(withEventAutofill(mergeConfig(sch, config), sch, eventDetails, eventForms));
acc.advance(2); acc.advance(2);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load template configuration'); setError(e instanceof Error ? e.message : 'Failed to load template configuration');

View File

@ -1,12 +1,13 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
const SCHEDULE_SECTION_ID = 'event_schedule';
import { uploadMediaFile } from '../../../services/api/media'; import { uploadMediaFile } from '../../../services/api/media';
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types'; import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
import { Field, Label, HelperText } from '../panels/PanelShell'; import { Field, Label, HelperText } from '../panels/PanelShell';
import { FlatpickrDateInput } from '../ui/FlatpickrDatetimeInput';
import { getNestedValue, setNestedValue } from './configUtils'; import { getNestedValue, setNestedValue } from './configUtils';
const SCHEDULE_SECTION_ID = 'event_schedule';
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', width: '100%',
padding: '8px 10px', padding: '8px 10px',
@ -18,16 +19,154 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box', boxSizing: 'border-box',
}; };
const mediaActionsStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
};
function MediaUploadActions({
uploading,
hasValue,
uploadLabel,
changeLabel,
onPickFile,
onRemove,
}: {
uploading: boolean;
hasValue: boolean;
uploadLabel: string;
changeLabel: string;
onPickFile: () => void;
onRemove: () => void;
}) {
return (
<div style={mediaActionsStyle}>
<button
type="button"
disabled={uploading}
onClick={onPickFile}
style={{
...inputStyle,
cursor: uploading ? 'wait' : 'pointer',
width: 'auto',
}}
>
{uploading ? 'Uploading…' : hasValue ? changeLabel : uploadLabel}
</button>
{hasValue ? (
<button
type="button"
disabled={uploading}
onClick={onRemove}
style={{
...inputStyle,
cursor: uploading ? 'wait' : 'pointer',
width: 'auto',
color: 'var(--destructive)',
borderColor: 'color-mix(in srgb, var(--destructive) 35%, var(--border))',
}}
>
Remove
</button>
) : null}
</div>
);
}
function resolveFieldType(field: MicrositeV2FieldSchema): MicrositeV2FieldType {
if (field.type === 'date' || field.type === 'time') return field.type;
if (field.key === 'countdown.date') return 'date';
if (field.key === 'countdown.time') return 'time';
return field.type;
}
function toDateInputValue(value: unknown): string {
const raw = String(value ?? '').trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
const parsed = Date.parse(raw);
if (!Number.isNaN(parsed)) {
const d = new Date(parsed);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
return '';
}
function parseTimeValue(value: unknown): { hour: string; minute: string } {
const raw = String(value ?? '12:00').trim();
const match = raw.match(/^(\d{1,2}):(\d{2})/);
if (!match) return { hour: '12', minute: '00' };
const hourNum = Math.min(23, Math.max(0, parseInt(match[1], 10) || 0));
const minuteNum = Math.min(59, Math.max(0, parseInt(match[2], 10) || 0));
return {
hour: String(hourNum).padStart(2, '0'),
minute: String(minuteNum).padStart(2, '0'),
};
}
function TimeSelect({
value,
onChange,
}: {
value: unknown;
onChange: (v: string) => void;
}) {
const { hour, minute } = parseTimeValue(value);
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
const selectStyle: React.CSSProperties = { ...inputStyle, width: 'auto', minWidth: '72px' };
const setTime = (nextHour: string, nextMinute: string) => {
onChange(`${nextHour}:${nextMinute}`);
};
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<select
value={hour}
onChange={(e) => setTime(e.target.value, minute)}
style={selectStyle}
aria-label="Hour"
>
{hours.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
<span style={{ color: 'var(--muted)', fontWeight: 600 }}>:</span>
<select
value={minute}
onChange={(e) => setTime(hour, e.target.value)}
style={selectStyle}
aria-label="Minute"
>
{minutes.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
);
}
function FieldInput({ function FieldInput({
field, field,
value, value,
onChange, onChange,
eventId, eventId,
arrayDepth = 0,
}: { }: {
field: MicrositeV2FieldSchema; field: MicrositeV2FieldSchema;
value: unknown; value: unknown;
onChange: (v: unknown) => void; onChange: (v: unknown) => void;
eventId: string; eventId: string;
arrayDepth?: number;
}) { }) {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = React.useState(false); const [uploading, setUploading] = React.useState(false);
@ -44,7 +183,7 @@ function FieldInput({
} }
}; };
switch (field.type) { switch (resolveFieldType(field)) {
case 'boolean': case 'boolean':
return ( return (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}>
@ -86,6 +225,17 @@ function FieldInput({
))} ))}
</select> </select>
); );
case 'date':
return (
<FlatpickrDateInput
value={toDateInputValue(value)}
onChange={(v) => onChange(v)}
style={inputStyle}
placeholder={field.placeholder || 'Select date…'}
/>
);
case 'time':
return <TimeSelect value={value} onChange={onChange} />;
case 'image': case 'image':
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
@ -101,19 +251,14 @@ function FieldInput({
if (f) void handleUpload(f); if (f) void handleUpload(f);
e.target.value = ''; e.target.value = '';
}} /> }} />
<button <MediaUploadActions
type="button" uploading={uploading}
disabled={uploading} hasValue={Boolean(value)}
onClick={() => fileRef.current?.click()} uploadLabel="Upload image"
style={{ changeLabel="Change image"
...inputStyle, onPickFile={() => fileRef.current?.click()}
cursor: uploading ? 'wait' : 'pointer', onRemove={() => onChange('')}
width: 'auto', />
alignSelf: 'flex-start',
}}
>
{uploading ? 'Uploading…' : value ? 'Change image' : 'Upload image'}
</button>
</div> </div>
); );
case 'video': case 'video':
@ -131,19 +276,14 @@ function FieldInput({
if (f) void handleUpload(f); if (f) void handleUpload(f);
e.target.value = ''; e.target.value = '';
}} /> }} />
<button <MediaUploadActions
type="button" uploading={uploading}
disabled={uploading} hasValue={Boolean(value)}
onClick={() => fileRef.current?.click()} uploadLabel="Upload video"
style={{ changeLabel="Change video"
...inputStyle, onPickFile={() => fileRef.current?.click()}
cursor: uploading ? 'wait' : 'pointer', onRemove={() => onChange('')}
width: 'auto', />
alignSelf: 'flex-start',
}}
>
{uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
</button>
</div> </div>
); );
case 'array': case 'array':
@ -153,6 +293,7 @@ function FieldInput({
value={Array.isArray(value) ? value : []} value={Array.isArray(value) ? value : []}
onChange={onChange} onChange={onChange}
eventId={eventId} eventId={eventId}
depth={arrayDepth}
/> />
); );
default: default:
@ -173,18 +314,32 @@ function ArrayFieldEditor({
value, value,
onChange, onChange,
eventId, eventId,
depth = 0,
}: { }: {
field: MicrositeV2FieldSchema; field: MicrositeV2FieldSchema;
value: Record<string, unknown>[]; value: Record<string, unknown>[];
onChange: (v: Record<string, unknown>[]) => void; onChange: (v: Record<string, unknown>[]) => void;
eventId: string; eventId: string;
depth?: number;
}) { }) {
const itemFields = field.itemFields || []; const itemFields = field.itemFields || [];
const isTopicsList = field.key === 'topics';
const itemLabel = isTopicsList ? 'Topic' : field.label || 'item';
const nestedStyle: React.CSSProperties = isTopicsList
? {
borderLeft: '3px solid color-mix(in srgb, var(--primary) 35%, var(--border))',
paddingLeft: '12px',
marginTop: '4px',
}
: {};
const addRow = () => { const addRow = () => {
const row: Record<string, unknown> = {}; const row: Record<string, unknown> = {};
for (const f of itemFields) { for (const f of itemFields) {
if (f.key) row[f.key] = f.default ?? ''; if (f.key) {
if (f.type === 'array') row[f.key] = [];
else row[f.key] = f.default ?? '';
}
} }
onChange([...value, row]); onChange([...value, row]);
}; };
@ -199,7 +354,7 @@ function ArrayFieldEditor({
}; };
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px', ...nestedStyle }}>
{value.map((row, idx) => ( {value.map((row, idx) => (
<div <div
key={idx} key={idx}
@ -213,7 +368,9 @@ function ArrayFieldEditor({
}} }}
> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>Item {idx + 1}</span> <span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
{itemLabel} {idx + 1}
</span>
<button type="button" onClick={() => removeRow(idx)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}> <button type="button" onClick={() => removeRow(idx)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}>
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@ -226,6 +383,7 @@ function ArrayFieldEditor({
value={row[f.key]} value={row[f.key]}
onChange={(v) => updateRow(idx, f.key, v)} onChange={(v) => updateRow(idx, f.key, v)}
eventId={eventId} eventId={eventId}
arrayDepth={depth + 1}
/> />
</div> </div>
))} ))}
@ -249,7 +407,7 @@ function ArrayFieldEditor({
}} }}
> >
<Plus size={14} /> <Plus size={14} />
Add {field.label || 'item'} Add {isTopicsList ? 'Session Topic' : field.label || 'item'}
</button> </button>
</div> </div>
); );

View File

@ -0,0 +1,275 @@
import type {
Event,
FormWithSubmissionCount,
MicrositeV2TemplateSchema,
StaticMicrositeConfig,
} from '../../../services/api/types';
import { getNestedValue, setNestedValue } from './configUtils';
export interface EventMicrositeAutofillOptions {
registrationUrl?: string | null;
feedbackUrl?: string | null;
contactFormId?: string | null;
}
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(/\/+$/, '');
if (import.meta.env.DEV) return 'http://localhost:5175';
return (typeof window !== 'undefined' ? window.location.origin : '').replace(/\/+$/, '');
}
function isBlank(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) return value.length === 0;
return false;
}
function isDefaultOrBlank(value: unknown, schemaDefault: unknown): boolean {
if (isBlank(value)) return true;
if (schemaDefault !== undefined && value === schemaDefault) return true;
if (value === '#') return true;
return false;
}
function fieldDefault(schema: MicrositeV2TemplateSchema, sectionId: string, fieldKey: string): unknown {
const section = schema.sections.find((s) => s.id === sectionId);
const field = section?.fields.find((f) => f.key === fieldKey);
return field?.default;
}
function parseDate(value: string | undefined): Date | null {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
function toYmd(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function toHm(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function formatDateRangeShort(start: Date, end: Date | null): string {
const monthDay: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
if (
!end ||
(start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate())
) {
return start.toLocaleDateString('en-US', { ...monthDay, year: 'numeric' });
}
if (start.getFullYear() === end.getFullYear() && start.getMonth() === end.getMonth()) {
const month = start.toLocaleDateString('en-US', { month: 'short' });
return `${month} ${start.getDate()}–${end.getDate()}, ${start.getFullYear()}`;
}
const s = start.toLocaleDateString('en-US', { ...monthDay, year: 'numeric' });
const e = end.toLocaleDateString('en-US', { ...monthDay, year: 'numeric' });
return `${s} – ${e}`;
}
function heroEventName(event: Event): string {
if (event.subtitle?.trim()) return event.subtitle.trim();
return event.name?.trim() || '';
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function descriptionToHtml(description: string): string {
const trimmed = description.trim();
if (!trimmed) return '';
if (/<[a-z][\s\S]*>/i.test(trimmed)) return trimmed;
return trimmed
.split(/\n\n+/)
.filter(Boolean)
.map((paragraph) => `<p>${escapeHtml(paragraph.trim().replace(/\n/g, '<br />'))}</p>`)
.join('');
}
function shouldFill(
sectionData: Record<string, unknown>,
fieldKey: string,
schema: MicrositeV2TemplateSchema,
sectionId: string,
): boolean {
const current = fieldKey.includes('.')
? getNestedValue(sectionData, fieldKey)
: sectionData[fieldKey];
return isDefaultOrBlank(current, fieldDefault(schema, sectionId, fieldKey));
}
function fillIfEmpty(
sectionData: Record<string, unknown>,
path: string,
value: unknown,
schema: MicrositeV2TemplateSchema,
sectionId: string,
): Record<string, unknown> {
if (value == null || (typeof value === 'string' && !value.trim())) return sectionData;
if (!shouldFill(sectionData, path, schema, sectionId)) return sectionData;
return setNestedValue(sectionData, path, value);
}
export function buildEventRegistrationUrl(event: Event, forms: FormWithSubmissionCount[] = []): string {
const base = publicWebBase();
const websiteUrl = event.website?.registrationUrl?.trim();
if (websiteUrl) return websiteUrl;
const websiteFormId = event.website?.registrationFormId?.trim();
if (websiteFormId) return `${base}/event/${event._id}/form/${websiteFormId}`;
const registrationForm = forms.find((f) => f.type === 'registration' && f.settings?.isActive !== false);
if (registrationForm) return `${base}/event/${event._id}/form/${registrationForm._id}`;
return `${base}/event/${event._id}/register`;
}
export function buildEventFeedbackUrl(event: Event, forms: FormWithSubmissionCount[] = []): string | null {
const base = publicWebBase();
const feedbackForm = forms.find((f) => f.type === 'feedback' && f.settings?.isActive !== false);
if (!feedbackForm) return null;
return `${base}/event/${event._id}/form/${feedbackForm._id}`;
}
export function findContactFormId(forms: FormWithSubmissionCount[] = []): string | null {
const contact = forms.find(
(f) => f.type === 'other' && /contact/i.test(f.name) && f.settings?.isActive !== false,
);
return contact?._id ?? null;
}
/** Fill empty / template-default microsite fields from the event record; never overwrites user edits. */
export function applyEventDetailsToMicrositeConfig(
config: StaticMicrositeConfig,
event: Event,
schema: MicrositeV2TemplateSchema,
options: EventMicrositeAutofillOptions = {},
): StaticMicrositeConfig {
const next: StaticMicrositeConfig = {
meta: { ...(config.meta || {}) },
theme: { ...(config.theme || {}) },
sections: { ...(config.sections || {}) },
};
const metaTitleDefault = schema.meta?.title;
if (isDefaultOrBlank(next.meta?.title, metaTitleDefault) && event.name?.trim()) {
next.meta = { ...next.meta, title: event.name.trim() };
}
if (isBlank(next.meta?.eventId) && event._id) {
next.meta = { ...next.meta, eventId: event._id };
}
const start = parseDate(event.startDate);
const end = parseDate(event.endDate);
const location = event.location?.trim() || '';
const bannerUrl = event.bannerImage?.trim() || event.image?.trim() || '';
const dateShort = start ? formatDateRangeShort(start, end) : '';
const countdownDate = start ? toYmd(start) : '';
const countdownTime = start ? toHm(start) : '12:00';
let hero = { ...((next.sections?.hero as Record<string, unknown>) || {}) };
hero = fillIfEmpty(hero, 'event_name', heroEventName(event), schema, 'hero');
hero = fillIfEmpty(hero, 'date_venue', location, schema, 'hero');
if (bannerUrl) hero = fillIfEmpty(hero, 'image.url', bannerUrl, schema, 'hero');
if (countdownDate) hero = fillIfEmpty(hero, 'countdown.date', countdownDate, schema, 'hero');
if (countdownTime) hero = fillIfEmpty(hero, 'countdown.time', countdownTime, schema, 'hero');
if (options.registrationUrl) {
hero = fillIfEmpty(hero, 'cta.url', options.registrationUrl, schema, 'hero');
hero = fillIfEmpty(hero, 'cta.target', '_blank', schema, 'hero');
}
next.sections = { ...next.sections, hero };
let about = { ...((next.sections?.about as Record<string, unknown>) || {}) };
const aboutHtml = event.description ? descriptionToHtml(event.description) : '';
if (aboutHtml) about = fillIfEmpty(about, 'content', aboutHtml, schema, 'about');
next.sections = { ...next.sections, about };
let info = { ...((next.sections?.informationBar as Record<string, unknown>) || {}) };
let items = Array.isArray(info.items) ? [...(info.items as Record<string, unknown>[])] : [];
if (items.length === 0 && (location || dateShort)) {
items = [
{ icon: 'lni-map-marker', label: 'Location', value: location, action: 'maps' },
{
icon: 'lni-calendar',
label: 'Date & Time',
value: dateShort,
action: 'calendar',
calendar: {
title: event.name?.trim() || '',
start: countdownDate,
end: end ? toYmd(end) : countdownDate,
location,
},
},
];
} else {
items = items.map((item) => {
const copy = { ...item };
if (copy.label === 'Location' && isBlank(copy.value) && location) {
copy.value = location;
}
if (copy.label === 'Date & Time') {
if (isBlank(copy.value) && dateShort) copy.value = dateShort;
const cal =
copy.calendar && typeof copy.calendar === 'object'
? { ...(copy.calendar as Record<string, unknown>) }
: {};
if (isBlank(cal.title) && event.name?.trim()) cal.title = event.name.trim();
if (isBlank(cal.start) && countdownDate) cal.start = countdownDate;
if (isBlank(cal.end) && end) cal.end = toYmd(end);
if (isBlank(cal.location) && location) cal.location = location;
if (Object.keys(cal).length > 0) copy.calendar = cal;
}
return copy;
});
}
info = { ...info, items };
next.sections = { ...next.sections, informationBar: info };
if (options.registrationUrl) {
const footer = { ...((next.sections?.footer as Record<string, unknown>) || {}) };
if (Array.isArray(footer.links)) {
footer.links = (footer.links as Record<string, unknown>[]).map((link) => {
if (link.text && /register/i.test(String(link.text)) && isBlank(link.url)) {
return { ...link, url: options.registrationUrl, target: link.target || '_blank' };
}
return link;
});
next.sections = { ...next.sections, footer };
}
}
if (options.contactFormId) {
const contact = { ...((next.sections?.contact as Record<string, unknown>) || {}) };
if (isBlank(contact.formId)) {
contact.formId = options.contactFormId;
if (contact.show == null) contact.show = true;
next.sections = { ...next.sections, contact };
}
}
if (options.feedbackUrl) {
let feedback = { ...((next.sections?.feedback as Record<string, unknown>) || {}) };
feedback = fillIfEmpty(feedback, 'url', options.feedbackUrl, schema, 'feedback');
next.sections = { ...next.sections, feedback };
}
return next;
}

View File

@ -14,11 +14,22 @@ export interface ScheduleSession {
time: string; time: string;
type: string; type: string;
title: string; title: string;
/** Session name for block rows with topics */
name?: string;
/** Nested session topics */
topics?: ScheduleTopic[];
/** Agenda session _id for synced rows; absent for manually added rows. */ /** Agenda session _id for synced rows; absent for manually added rows. */
sourceId?: string; sourceId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export interface ScheduleTopic {
type?: string;
duration?: string;
title?: string;
role?: string;
}
export interface ScheduleDay { export interface ScheduleDay {
label: string; label: string;
date: string; date: string;

View File

@ -1,11 +1,13 @@
/** /**
* Live badge preview canvas — draggable elements, resize handles, sample data. * Live badge preview canvas — draggable elements, resize handles, sample data.
*/ */
import React, { useCallback, useEffect, useRef } from "react"; import React, { useCallback, useEffect, useRef, type RefObject } from "react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { ImageIcon } from "lucide-react"; import { ImageIcon } from "lucide-react";
import type { BadgeLayoutElement } from "./badgeLayout"; import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import { import {
BADGE_BACKGROUND_IMAGE_ID,
applyBackgroundImageLayoutPatch,
applyElementSize, applyElementSize,
clampElementPosition, clampElementPosition,
elementSizePct, elementSizePct,
@ -39,11 +41,27 @@ type ResizeState = {
origW: number; origW: number;
}; };
type BgDragState = {
startX: number;
startY: number;
origX: number;
origY: number;
};
type BgResizeState = {
startX: number;
startY: number;
origX: number;
origY: number;
origW: number;
origH: number;
};
export function BadgeDesignerPreview({ export function BadgeDesignerPreview({
aspectRatio, aspectRatio,
backgroundMode,
backgroundColor, backgroundColor,
backgroundImageUrl, backgroundImageUrl,
backgroundImageLayout,
layoutElements, layoutElements,
customImages, customImages,
badgeTypeLabel, badgeTypeLabel,
@ -51,31 +69,44 @@ export function BadgeDesignerPreview({
selectedId, selectedId,
onSelect, onSelect,
onLayoutChange, onLayoutChange,
onBackgroundImageLayoutChange,
surfaceRef,
}: { }: {
aspectRatio: number; aspectRatio: number;
badgeAspectRatio: number; badgeAspectRatio: number;
backgroundMode: "image" | "color";
backgroundColor: string; backgroundColor: string;
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundImageLayout: BadgeBackgroundImageLayout;
layoutElements: BadgeLayoutElement[]; layoutElements: BadgeLayoutElement[];
customImages: { id: string; imageUrl: string }[]; customImages: { id: string; imageUrl: string }[];
badgeTypeLabel: string; badgeTypeLabel: string;
selectedId: string | null; selectedId: string | null;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onLayoutChange: (next: BadgeLayoutElement[]) => void; onLayoutChange: (next: BadgeLayoutElement[]) => void;
onBackgroundImageLayoutChange: (next: BadgeBackgroundImageLayout) => void;
/** Canvas + bg-image handles — clicks outside this (and the inspector) dismiss selection */
surfaceRef?: RefObject<HTMLDivElement | null>;
}) { }) {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef(layoutElements); const layoutRef = useRef(layoutElements);
const bgLayoutRef = useRef(backgroundImageLayout);
const onLayoutChangeRef = useRef(onLayoutChange); const onLayoutChangeRef = useRef(onLayoutChange);
const onBgLayoutChangeRef = useRef(onBackgroundImageLayoutChange);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const resizeRef = useRef<ResizeState | null>(null); const resizeRef = useRef<ResizeState | null>(null);
const bgDragRef = useRef<BgDragState | null>(null);
const bgResizeRef = useRef<BgResizeState | null>(null);
const qrUrlRef = useRef(""); const qrUrlRef = useRef("");
const [, setTick] = React.useState(0); const [, setTick] = React.useState(0);
layoutRef.current = layoutElements; layoutRef.current = layoutElements;
bgLayoutRef.current = backgroundImageLayout;
onLayoutChangeRef.current = onLayoutChange; onLayoutChangeRef.current = onLayoutChange;
onBgLayoutChangeRef.current = onBackgroundImageLayoutChange;
const sample = samplePreviewData(badgeTypeLabel); const sample = samplePreviewData(badgeTypeLabel);
const hasBgImage = Boolean(backgroundImageUrl.trim());
const bgSelected = selectedId === BADGE_BACKGROUND_IMAGE_ID;
useEffect(() => { useEffect(() => {
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 }) QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
@ -99,6 +130,36 @@ export function BadgeDesignerPreview({
const box = canvasRef.current?.getBoundingClientRect(); const box = canvasRef.current?.getBoundingClientRect();
if (!box?.width || !box.height) return; if (!box?.width || !box.height) return;
const bgDrag = bgDragRef.current;
if (bgDrag) {
e.preventDefault();
const dx = ((e.clientX - bgDrag.startX) / box.width) * 100;
const dy = ((e.clientY - bgDrag.startY) / box.height) * 100;
const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
x: bgDrag.origX + dx,
y: bgDrag.origY + dy,
});
bgLayoutRef.current = next;
onBgLayoutChangeRef.current(next);
return;
}
const bgResize = bgResizeRef.current;
if (bgResize) {
e.preventDefault();
const dx = ((e.clientX - bgResize.startX) / box.width) * 100;
const dy = ((e.clientY - bgResize.startY) / box.height) * 100;
const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
x: bgResize.origX,
y: bgResize.origY,
w: bgResize.origW + dx,
h: bgResize.origH + dy,
});
bgLayoutRef.current = next;
onBgLayoutChangeRef.current(next);
return;
}
const drag = dragRef.current; const drag = dragRef.current;
if (drag) { if (drag) {
const el = layoutRef.current.find((x) => x.id === drag.id); const el = layoutRef.current.find((x) => x.id === drag.id);
@ -106,7 +167,7 @@ export function BadgeDesignerPreview({
e.preventDefault(); e.preventDefault();
const dx = ((e.clientX - drag.startX) / box.width) * 100; const dx = ((e.clientX - drag.startX) / box.width) * 100;
const dy = ((e.clientY - drag.startY) / box.height) * 100; const dy = ((e.clientY - drag.startY) / box.height) * 100;
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy); const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy, badgeAspectRatio);
patchElement(drag.id, pos); patchElement(drag.id, pos);
return; return;
} }
@ -126,6 +187,8 @@ export function BadgeDesignerPreview({
const end = () => { const end = () => {
dragRef.current = null; dragRef.current = null;
resizeRef.current = null; resizeRef.current = null;
bgDragRef.current = null;
bgResizeRef.current = null;
}; };
window.addEventListener("pointermove", onMove, { passive: false }); window.addEventListener("pointermove", onMove, { passive: false });
@ -136,10 +199,10 @@ export function BadgeDesignerPreview({
window.removeEventListener("pointerup", end); window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end); window.removeEventListener("pointercancel", end);
}; };
}, [patchElement]); }, [patchElement, badgeAspectRatio]);
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => { const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
if (resizeRef.current) return; if (resizeRef.current || bgDragRef.current || bgResizeRef.current) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onSelect(el.id); onSelect(el.id);
@ -165,52 +228,151 @@ export function BadgeDesignerPreview({
}; };
}; };
const bgStyle: React.CSSProperties = const startBgDrag = (e: React.PointerEvent) => {
backgroundMode === "image" && backgroundImageUrl.trim() if (bgResizeRef.current || resizeRef.current) return;
? { e.preventDefault();
backgroundImage: `url(${backgroundImageUrl})`, e.stopPropagation();
backgroundSize: "cover", onSelect(BADGE_BACKGROUND_IMAGE_ID);
backgroundPosition: "center", bgDragRef.current = {
} startX: e.clientX,
: { backgroundColor }; startY: e.clientY,
origX: backgroundImageLayout.x,
origY: backgroundImageLayout.y,
};
};
const startBgResize = (e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
onSelect(BADGE_BACKGROUND_IMAGE_ID);
bgDragRef.current = null;
bgResizeRef.current = {
startX: e.clientX,
startY: e.clientY,
origX: backgroundImageLayout.x,
origY: backgroundImageLayout.y,
origW: backgroundImageLayout.w,
origH: backgroundImageLayout.h,
};
};
return ( return (
<div style={{ padding: "8px 12px 16px 20px" }}> <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" }}> <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 Live preview — drag fields to move; drag corner handles to resize
{hasBgImage
? "; drag the background image to pan (it may extend outside the badge), resize to zoom"
: ""}
</p> </p>
<div <div
ref={canvasRef} ref={surfaceRef}
role="presentation"
onClick={() => onSelect(null)}
style={{ style={{
position: "relative",
width: "100%", width: "100%",
maxWidth: 420, 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) => ( <div
<PreviewElement ref={canvasRef}
key={el.id} role="presentation"
el={el} onClick={() => onSelect(null)}
sample={sample} style={{
customImages={customImages} width: "100%",
selected={selectedId === el.id} aspectRatio: String(aspectRatio),
qrUrl={qrUrlRef.current} position: "relative",
onDragStart={startDrag} borderRadius: "var(--radius-button)",
onResizeStart={startResize} border: "1px solid var(--border)",
/> boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
))} overflow: "hidden",
containerType: "size",
touchAction: "none",
backgroundColor,
cursor: "default",
userSelect: "none",
}}
>
{hasBgImage ? (
<div
onPointerDown={(e) => {
e.stopPropagation();
onSelect(BADGE_BACKGROUND_IMAGE_ID);
startBgDrag(e);
}}
onClick={(e) => e.stopPropagation()}
style={{
position: "absolute",
left: `${backgroundImageLayout.x}%`,
top: `${backgroundImageLayout.y}%`,
width: `${backgroundImageLayout.w}%`,
height: `${backgroundImageLayout.h}%`,
boxSizing: "border-box",
cursor: bgSelected ? "grab" : "pointer",
touchAction: "none",
zIndex: 0,
overflow: "hidden",
}}
>
<img
src={backgroundImageUrl}
alt=""
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
/>
</div>
) : null}
{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>
{hasBgImage && bgSelected ? (
<div
role="presentation"
style={{
position: "absolute",
left: `${backgroundImageLayout.x}%`,
top: `${backgroundImageLayout.y}%`,
width: `${backgroundImageLayout.w}%`,
height: `${backgroundImageLayout.h}%`,
border: "2px solid var(--primary)",
boxSizing: "border-box",
pointerEvents: "none",
zIndex: 6,
}}
>
<div
role="presentation"
onPointerDown={startBgResize}
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",
pointerEvents: "auto",
zIndex: 2,
}}
/>
</div>
) : null}
</div> </div>
</div> </div>
); );
@ -252,6 +414,7 @@ function PreviewElement({
cursor: "grab", cursor: "grab",
boxSizing: "border-box", boxSizing: "border-box",
touchAction: "none", touchAction: "none",
zIndex: 1,
...extra, ...extra,
}} }}
> >
@ -299,6 +462,7 @@ function PreviewElement({
touchAction: "none", touchAction: "none",
overflow: "hidden", overflow: "hidden",
background: "#fff", background: "#fff",
zIndex: 1,
}} }}
> >
{url ? ( {url ? (
@ -481,6 +645,7 @@ function TextPreviewBlock({
boxSizing: "border-box", boxSizing: "border-box",
textAlign: align, textAlign: align,
touchAction: "none", touchAction: "none",
zIndex: 1,
}} }}
> >
<div <div

View File

@ -45,6 +45,7 @@ export function BadgeFieldInspector({
customTextValue, customTextValue,
customImages, customImages,
badgeAspectRatio, badgeAspectRatio,
mediaEventId = "portfolio",
onChange, onChange,
onRemoveCustom, onRemoveCustom,
onCustomImageUploaded, onCustomImageUploaded,
@ -53,6 +54,7 @@ export function BadgeFieldInspector({
customTextValue?: string; customTextValue?: string;
customImages: { id: string; imageUrl: string }[]; customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number; badgeAspectRatio: number;
mediaEventId?: string;
onChange: (patch: Partial<BadgeLayoutElement>) => void; onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void; onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void; onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
@ -76,6 +78,7 @@ export function BadgeFieldInspector({
element={element} element={element}
customImages={customImages} customImages={customImages}
badgeAspectRatio={badgeAspectRatio} badgeAspectRatio={badgeAspectRatio}
mediaEventId={mediaEventId}
onChange={onChange} onChange={onChange}
onRemoveCustom={onRemoveCustom} onRemoveCustom={onRemoveCustom}
onCustomImageUploaded={onCustomImageUploaded} onCustomImageUploaded={onCustomImageUploaded}
@ -224,6 +227,7 @@ function ResizableInspector({
element, element,
customImages, customImages,
badgeAspectRatio, badgeAspectRatio,
mediaEventId = "portfolio",
onChange, onChange,
onRemoveCustom, onRemoveCustom,
onCustomImageUploaded, onCustomImageUploaded,
@ -231,6 +235,7 @@ function ResizableInspector({
element: BadgeLayoutElement; element: BadgeLayoutElement;
customImages: { id: string; imageUrl: string }[]; customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number; badgeAspectRatio: number;
mediaEventId?: string;
onChange: (patch: Partial<BadgeLayoutElement>) => void; onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void; onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void; onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
@ -250,7 +255,7 @@ function ResizableInspector({
setUploading(true); setUploading(true);
try { try {
const imageAspectRatio = await readFileImageAspectRatio(file); const imageAspectRatio = await readFileImageAspectRatio(file);
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" }); const r = await uploadMediaFile(file, { eventId: mediaEventId, module: "badges/fixed-images" });
if (r.url) { if (r.url) {
const w = element.w ?? 25; const w = element.w ?? 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio); const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);

View File

@ -19,18 +19,21 @@ import {
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api"; import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview"; import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
import { BadgeFieldInspector } from "./BadgeFieldInspector"; import { BadgeFieldInspector } from "./BadgeFieldInspector";
import type { BadgeLayoutElement } from "./badgeLayout"; import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import { import {
BADGE_BACKGROUND_IMAGE_ID,
MAX_CUSTOM_IMAGE_FIELDS, MAX_CUSTOM_IMAGE_FIELDS,
MAX_CUSTOM_TEXT_FIELDS, MAX_CUSTOM_TEXT_FIELDS,
applyElementSize, applyElementSize,
buildCustomImageElement, buildCustomImageElement,
clampElementPosition, clampElementPosition,
defaultBackgroundImageLayout,
defaultLayoutForFields, defaultLayoutForFields,
heightPctForImageWidth, heightPctForImageWidth,
newCustomImageId, newCustomImageId,
newCustomTextId, newCustomTextId,
readFileImageAspectRatio, readFileImageAspectRatio,
squareElementHeightPct,
syncLayoutWithFields, syncLayoutWithFields,
} from "./badgeLayout"; } from "./badgeLayout";
import { formStateFromBadgeBatch } from "./badgeBatchFormState"; import { formStateFromBadgeBatch } from "./badgeBatchFormState";
@ -179,9 +182,11 @@ export function CreateBadgePanel({
const [includeParticipants, setIncludeParticipants] = useState(true); const [includeParticipants, setIncludeParticipants] = useState(true);
const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all"); const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all");
const [participantListId, setParticipantListId] = useState(""); const [participantListId, setParticipantListId] = useState("");
const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color");
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1"); const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
const [backgroundImageUrl, setBackgroundImageUrl] = useState(""); const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
const [backgroundImageLayout, setBackgroundImageLayout] = useState<BadgeBackgroundImageLayout>(
defaultBackgroundImageLayout,
);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState(""); const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]); const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() => const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
@ -199,6 +204,9 @@ export function CreateBadgePanel({
const [headerBusy, setHeaderBusy] = useState(false); const [headerBusy, setHeaderBusy] = useState(false);
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId)); const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
const badgeSurfaceRef = useRef<HTMLDivElement>(null);
const badgeInspectorRef = useRef<HTMLDivElement>(null);
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH); const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
const isDuplicate = Boolean(duplicateFromBatchId); const isDuplicate = Boolean(duplicateFromBatchId);
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl; const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
@ -208,6 +216,18 @@ export function CreateBadgePanel({
[layoutElements, selectedElementId], [layoutElements, selectedElementId],
); );
useEffect(() => {
if (!selectedElementId) return;
const dismissSelection = (event: PointerEvent) => {
const target = event.target as Node;
if (badgeSurfaceRef.current?.contains(target)) return;
if (badgeInspectorRef.current?.contains(target)) return;
setSelectedElementId(null);
};
document.addEventListener("pointerdown", dismissSelection);
return () => document.removeEventListener("pointerdown", dismissSelection);
}, [selectedElementId]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl); if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
@ -220,14 +240,14 @@ export function CreateBadgePanel({
let merged = { ...el }; let merged = { ...el };
if (el.field === "qr") { if (el.field === "qr") {
const w = el.w ?? 28; const w = el.w ?? 28;
merged = { ...merged, w, h: w }; merged = { ...merged, w, h: squareElementHeightPct(w, aspectRatio) };
} else if (el.field === "photo" && el.w != null) { } else if (el.field === "photo" && el.w != null) {
merged = { ...merged, h: el.w }; merged = { ...merged, h: squareElementHeightPct(el.w, aspectRatio) };
} else if (el.kind === "custom_image" && el.imageAspectRatio) { } else if (el.kind === "custom_image" && el.imageAspectRatio) {
const w = el.w ?? 25; const w = el.w ?? 25;
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) }; merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
} }
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) }; return { ...merged, ...clampElementPosition(merged, merged.x, merged.y, aspectRatio) };
}), }),
); );
}, [sizePreset, orientation, customW, customH, aspectRatio]); }, [sizePreset, orientation, customW, customH, aspectRatio]);
@ -258,9 +278,9 @@ export function CreateBadgePanel({
setIncludeParticipants(s.includeParticipants); setIncludeParticipants(s.includeParticipants);
setParticipantScope(s.participantScope); setParticipantScope(s.participantScope);
setParticipantListId(s.participantListId); setParticipantListId(s.participantListId);
setBackgroundMode(s.backgroundMode);
setBackgroundColor(s.backgroundColor); setBackgroundColor(s.backgroundColor);
setBackgroundImageUrl(s.backgroundImageUrl); setBackgroundImageUrl(s.backgroundImageUrl);
setBackgroundImageLayout(s.backgroundImageLayout);
setBackgroundPreviewUrl(""); setBackgroundPreviewUrl("");
setEnabledFields(s.enabledFields); setEnabledFields(s.enabledFields);
setLayoutElements(s.layoutElements); setLayoutElements(s.layoutElements);
@ -302,7 +322,7 @@ export function CreateBadgePanel({
: [...enabledFields, id]; : [...enabledFields, id];
setEnabledFields(next); setEnabledFields(next);
setLayoutElements((prev) => setLayoutElements((prev) =>
syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, backgroundMode, aspectRatio), syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, "color", aspectRatio),
); );
if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null); if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null);
}; };
@ -314,11 +334,13 @@ export function CreateBadgePanel({
const nextCustom = [...customTexts, { id, text }]; const nextCustom = [...customTexts, { id, text }];
setCustomTexts(nextCustom); setCustomTexts(nextCustom);
setLayoutElements((prev) => setLayoutElements((prev) =>
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio), syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, "color", aspectRatio),
); );
setSelectedElementId(id); setSelectedElementId(id);
}; };
const badgeMediaEventId = formEventId.trim() || "portfolio";
const addCustomImage = async (file: File) => { const addCustomImage = async (file: File) => {
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return; if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
@ -330,7 +352,7 @@ export function CreateBadgePanel({
setErr(""); setErr("");
try { try {
const imageAspectRatio = await readFileImageAspectRatio(file); const imageAspectRatio = await readFileImageAspectRatio(file);
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" }); const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/fixed-images" });
if (!r.url) throw new Error("Upload failed"); if (!r.url) throw new Error("Upload failed");
const nextImages = [...customImages, { id, imageUrl: r.url }]; const nextImages = [...customImages, { id, imageUrl: r.url }];
setCustomImages(nextImages); setCustomImages(nextImages);
@ -398,11 +420,13 @@ export function CreateBadgePanel({
setBackgroundPreviewUrl(local); setBackgroundPreviewUrl(local);
setHeaderBusy(true); setHeaderBusy(true);
setErr(""); setErr("");
const hadBgImage = Boolean(backgroundImageUrl.trim());
try { try {
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/backgrounds" }); const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
if (r.url) { if (r.url) {
setBackgroundImageUrl(r.url); setBackgroundImageUrl(r.url);
setBackgroundMode("image"); if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
} }
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : "Upload failed"); setErr(e instanceof Error ? e.message : "Upload failed");
@ -434,10 +458,6 @@ export function CreateBadgePanel({
setErr("Select a contact list."); setErr("Select a contact list.");
return; return;
} }
if (backgroundMode === "image" && !backgroundImageUrl.trim()) {
setErr("Upload a background image or switch to solid colour.");
return;
}
const hasCustom = const hasCustom =
layoutElements.some((el) => el.kind === "custom_text") || layoutElements.some((el) => el.kind === "custom_text") ||
layoutElements.some((el) => el.kind === "custom_image"); layoutElements.some((el) => el.kind === "custom_image");
@ -495,8 +515,9 @@ export function CreateBadgePanel({
includeParticipants, includeParticipants,
participantScope, participantScope,
...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}), ...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}),
backgroundMode, backgroundMode: backgroundImageUrl.trim() ? "image" : "color",
backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "", backgroundImageUrl: backgroundImageUrl.trim(),
...(backgroundImageUrl.trim() ? { backgroundImageLayout } : {}),
backgroundColor, backgroundColor,
fields: enabledFields, fields: enabledFields,
layout: layoutPayload, layout: layoutPayload,
@ -548,21 +569,25 @@ export function CreateBadgePanel({
<BadgeDesignerPreview <BadgeDesignerPreview
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
badgeAspectRatio={aspectRatio} badgeAspectRatio={aspectRatio}
backgroundMode={backgroundMode}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
backgroundImageUrl={displayBgUrl} backgroundImageUrl={displayBgUrl}
backgroundImageLayout={backgroundImageLayout}
layoutElements={layoutElements} layoutElements={layoutElements}
customImages={customImages} customImages={customImages}
badgeTypeLabel={badgeTypeLabel} badgeTypeLabel={badgeTypeLabel}
selectedId={selectedElementId} selectedId={selectedElementId}
onSelect={setSelectedElementId} onSelect={setSelectedElementId}
onLayoutChange={setLayoutElements} onLayoutChange={setLayoutElements}
onBackgroundImageLayoutChange={setBackgroundImageLayout}
surfaceRef={badgeSurfaceRef}
/> />
<BadgeFieldInspector <div ref={badgeInspectorRef}>
element={selectedElement} <BadgeFieldInspector
element={selectedElementId === BADGE_BACKGROUND_IMAGE_ID ? null : selectedElement}
customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text} customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text}
customImages={customImages} customImages={customImages}
badgeAspectRatio={aspectRatio} badgeAspectRatio={aspectRatio}
mediaEventId={badgeMediaEventId}
onChange={updateSelectedElement} onChange={updateSelectedElement}
onRemoveCustom={ onRemoveCustom={
selectedElement?.kind === "custom_text" selectedElement?.kind === "custom_text"
@ -576,6 +601,7 @@ export function CreateBadgePanel({
updateSelectedElement({ imageUrl: url, imageAspectRatio }); updateSelectedElement({ imageUrl: url, imageAspectRatio });
}} }}
/> />
</div>
</div> </div>
</div> </div>
@ -722,34 +748,53 @@ export function CreateBadgePanel({
nextLabel="Output" nextLabel="Output"
> >
<Field> <Field>
<Label text="Background" required /> <Label text="Background colour" required />
<RadioGroup <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
name="bgMode" <input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
value={backgroundMode} <input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
onChange={(v) => setBackgroundMode(v as "image" | "color")} </div>
options={[ <HelperText text="Always applied behind badge fields. Shows through transparent areas of the background image." />
{ label: "Solid colour", value: "color" },
{ label: "Image", value: "image" },
]}
/>
</Field> </Field>
{backgroundMode === "color" && ( <Field>
<Field> <Label text="Background image (optional)" />
<Label text="Colour" /> <input style={IS} value={backgroundImageUrl} onChange={(e) => {
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> const v = e.target.value;
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} /> setBackgroundImageUrl(v);
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} /> if (!v.trim()) {
</div> setBackgroundPreviewUrl("");
</Field> if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
)} }
{backgroundMode === "image" && ( }} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
<Field> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
<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)} /> <BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
<HelperText text="Must be reachable by the server when the worker runs (public HTTPS)." /> {backgroundImageUrl.trim() ? (
</Field> <>
)} <button
type="button"
onClick={() => {
setBackgroundImageLayout(defaultBackgroundImageLayout());
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
}}
style={{ ...SS, padding: "8px 12px", fontSize: "var(--text-sm)" }}
>
Fill badge
</button>
<button
type="button"
onClick={() => {
setBackgroundImageUrl("");
setBackgroundPreviewUrl("");
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
}}
style={{ ...SS, padding: "8px 12px", fontSize: "var(--text-sm)" }}
>
Remove image
</button>
</>
) : null}
</div>
<HelperText text="Covers the badge by default. Drag to pan any part of the image into view; resize to zoom. The image can extend outside the badge — only the visible area is printed." />
</Field>
<Field> <Field>
<Label text="Badge fields" /> <Label text="Badge fields" />
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}> <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>

View File

@ -24,6 +24,7 @@ const STATUS_OPTIONS: { label: string; value: string }[] = [
{ label: "Confirmed", value: "confirmed" }, { label: "Confirmed", value: "confirmed" },
{ label: "Checked In", value: "checked_in" }, { label: "Checked In", value: "checked_in" },
{ label: "Cancelled", value: "cancelled" }, { label: "Cancelled", value: "cancelled" },
{ label: "Rejected", value: "rejected" },
]; ];
const emptyForm = (): Form => ({ const emptyForm = (): Form => ({

View File

@ -0,0 +1,443 @@
import React, { useEffect, useState } from "react";
import {
campaignsApi,
participantListsApi,
designTemplatesApi,
portfolioListOrganizationId,
type ParticipantListRecord,
type DesignTemplate,
type CampaignRecipients,
} from "../../../services/api";
import { useAuth } from "../../contexts/AuthContext";
import { RichEmailEditor } from "../messaging/RichEmailEditor";
import { EMAIL_PLACEHOLDERS } from "../messaging/mergePlaceholders";
import { PanelShell, RadioGroup, SelectWrap, IS, SS, focusIn, focusOut } from "./PanelShell";
import { PARTICIPANT_STATUS, type ParticipantStatus } from "../../../config/constants";
const btnPrimary: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
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)",
};
const btnSecondary: React.CSSProperties = {
...btnPrimary,
background: "var(--secondary)",
color: "var(--card-foreground)",
border: "1px solid var(--border)",
};
const chipBtn: React.CSSProperties = {
fontSize: "10px",
padding: "4px 8px",
borderRadius: 99,
border: "1px solid var(--border)",
background: "var(--secondary)",
cursor: "pointer",
fontFamily: "var(--font-noto-sans)",
};
const STATUS_LABELS: Record<ParticipantStatus, string> = {
pending: "Pending",
confirmed: "Confirmed",
checked_in: "Checked In",
cancelled: "Cancelled",
rejected: "Rejected",
};
type RecipientSource = "participant_list" | "participants";
function buildRecipients(
source: RecipientSource,
listId: string,
status: "" | ParticipantStatus,
): CampaignRecipients | { error: string } {
if (source === "participant_list") {
if (!listId) return { error: "Select a contact list." };
return { type: "participant_list", participantList: listId };
}
if (!status) {
return { type: "all" };
}
return { type: "filtered", filters: { status } };
}
export function CreateEmailCampaignPanel({
eventId,
onClose,
onSuccess,
}: {
eventId: string;
onClose: () => void;
onSuccess?: () => void;
}) {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const [lists, setLists] = useState<ParticipantListRecord[]>([]);
const [templates, setTemplates] = useState<DesignTemplate[]>([]);
const [formName, setFormName] = useState("");
const [recipientSource, setRecipientSource] = useState<RecipientSource>("participant_list");
const [formListId, setFormListId] = useState("");
const [formStatus, setFormStatus] = useState<"" | ParticipantStatus>("");
const [msgMode, setMsgMode] = useState<"manual" | "template">("template");
const [formTemplateId, setFormTemplateId] = useState("");
const [formSubject, setFormSubject] = useState("");
const [formBody, setFormBody] = useState("<p>Hello {{contact.name}},</p><p>…</p>");
const [scheduleLocal, setScheduleLocal] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
/** After first save, keep id so send/schedule can reuse without creating duplicates. */
const [createdId, setCreatedId] = useState<string | null>(null);
useEffect(() => {
if (!listOrgId) return;
participantListsApi.list({ organization: listOrgId }).then((r) => {
if (r.data?.lists) {
setLists(r.data.lists);
setFormListId((prev) => prev || r.data!.lists![0]?._id || "");
}
});
designTemplatesApi.list({ type: "emailer", organization: listOrgId, limit: 100 }).then((r) => {
if (r.data?.templates) {
setTemplates(r.data.templates);
setFormTemplateId((prev) => prev || r.data!.templates![0]?._id || "");
}
});
}, [listOrgId]);
useEffect(() => {
if (msgMode !== "template") return;
if (formTemplateId) return;
const first = templates[0]?._id;
if (first) setFormTemplateId(first);
}, [msgMode, formTemplateId, templates]);
const resolveRecipients = (): CampaignRecipients | null => {
const built = buildRecipients(recipientSource, formListId, formStatus);
if ("error" in built) {
setErr(built.error);
return null;
}
return built;
};
const saveDraft = async (): Promise<string | null> => {
if (!formName.trim()) {
setErr("Campaign name is required.");
return null;
}
const recipients = resolveRecipients();
if (!recipients) return null;
if (msgMode === "template" && !formTemplateId) {
setErr("Select an emailer template.");
return null;
}
if (msgMode === "manual") {
const bodyText = formBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
if (!formSubject.trim() || !bodyText) {
setErr("Subject and message body are required for a manual message.");
return null;
}
}
setSaving(true);
setErr("");
const content =
msgMode === "template"
? { subject: "", body: "", template: formTemplateId }
: { subject: formSubject, body: formBody, template: "" };
if (createdId) {
const r = await campaignsApi.update(createdId, { name: formName, recipients, content });
setSaving(false);
if (r.error) {
setErr(r.error);
return null;
}
return createdId;
}
const r = await campaignsApi.create(eventId, {
name: formName,
channel: "email",
type: "broadcast",
recipients,
content,
});
setSaving(false);
if (r.error) {
setErr(r.error);
return null;
}
if (r.data) {
setCreatedId(r.data._id);
return r.data._id;
}
return null;
};
const finish = () => {
onSuccess?.();
onClose();
};
const saveDraftClose = async () => {
const id = await saveDraft();
if (id) finish();
};
const sendNow = async () => {
if (createdId) {
if (!formName.trim()) {
setErr("Campaign name is required.");
return;
}
const recipients = resolveRecipients();
if (!recipients) return;
if (msgMode === "template" && !formTemplateId) {
setErr("Select an emailer template.");
return;
}
if (msgMode === "manual") {
const bodyText = formBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
if (!formSubject.trim() || !bodyText) {
setErr("Subject and message body are required for a manual message.");
return;
}
}
setSaving(true);
setErr("");
const content =
msgMode === "template"
? { subject: "", body: "", template: formTemplateId }
: { subject: formSubject, body: formBody, template: "" };
const r = await campaignsApi.send(createdId, { name: formName, recipients, content });
setSaving(false);
if (r.error) setErr(r.error);
else finish();
return;
}
const id = await saveDraft();
if (!id) return;
const r = await campaignsApi.send(id, {});
if (r.error) setErr(r.error);
else finish();
};
const schedule = async () => {
if (!scheduleLocal) {
setErr("Pick a date and time to schedule.");
return;
}
const id = await saveDraft();
if (!id) return;
const iso = new Date(scheduleLocal).toISOString();
const r = await campaignsApi.schedule(id, iso);
if (r.error) setErr(r.error);
else finish();
};
const insertToken = (token: string) => {
setFormBody((prev) => prev + token);
};
return (
<PanelShell
onClose={onClose}
title="New email campaign"
breadcrumb="Event Home · Email Campaign"
description="Create an email campaign for this event, choose recipients, then save, send, or schedule."
completedCount={0}
totalSections={1}
ctaLabel=""
showProgress={false}
hidePrimaryCta
panelWidth="min(720px, 100vw)"
>
<div style={{ padding: "20px 24px", display: "flex", flexDirection: "column", gap: 14 }}>
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>}
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Campaign name</span>
<input
style={IS}
value={formName}
onChange={(e) => setFormName(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
placeholder="e.g. Registration reminder"
/>
</label>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Recipients</span>
<RadioGroup
name="email-campaign-recipients"
value={recipientSource}
onChange={(v) => setRecipientSource(v as RecipientSource)}
options={[
{ label: "Contact list", value: "participant_list" },
{ label: "All participants", value: "participants" },
]}
/>
</div>
{recipientSource === "participant_list" ? (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
<SelectWrap>
<select
style={SS}
value={formListId}
onChange={(e) => setFormListId(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select…</option>
{lists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
</SelectWrap>
</label>
) : (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Participant status</span>
<SelectWrap>
<select
style={SS}
value={formStatus}
onChange={(e) => setFormStatus(e.target.value as "" | ParticipantStatus)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">All statuses</option>
{PARTICIPANT_STATUS.map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</SelectWrap>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
Sends to participants registered for this event
{formStatus ? ` with status ${STATUS_LABELS[formStatus]}` : ""}.
</p>
</label>
)}
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => setMsgMode("template")}
style={msgMode === "template" ? btnPrimary : btnSecondary}
>
Design template
</button>
<button
type="button"
onClick={() => setMsgMode("manual")}
style={msgMode === "manual" ? btnPrimary : btnSecondary}
>
Manual message
</button>
</div>
{msgMode === "template" && (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span>
<SelectWrap>
<select
style={SS}
value={formTemplateId}
onChange={(e) => setFormTemplateId(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select…</option>
{templates.map((t) => (
<option key={t._id} value={t._id}>
{t.name}
</option>
))}
</select>
</SelectWrap>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
Subject and body come from this template. The email subject defaults to your campaign name if the
template does not set one.
</p>
</label>
)}
{msgMode === "manual" && (
<>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Subject</span>
<input
style={IS}
value={formSubject}
onChange={(e) => setFormSubject(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
placeholder="Subject line (placeholders ok)"
/>
</label>
<div>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500, display: "block", marginBottom: 8 }}>
Message body
</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 8 }}>
{EMAIL_PLACEHOLDERS.map((p) => (
<button key={p.token} type="button" onClick={() => insertToken(p.token)} style={chipBtn}>
{p.label}
</button>
))}
</div>
<RichEmailEditor value={formBody} onChange={setFormBody} placeholder="Compose your email…" />
</div>
</>
)}
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Schedule (optional)</span>
<input
type="datetime-local"
style={IS}
value={scheduleLocal}
onChange={(e) => setScheduleLocal(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</label>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
<button type="button" disabled={saving} onClick={() => void saveDraftClose()} style={btnSecondary}>
Save draft
</button>
<button type="button" disabled={saving} onClick={() => void sendNow()} style={btnPrimary}>
Send now
</button>
<button
type="button"
disabled={saving || !scheduleLocal}
onClick={() => void schedule()}
style={btnPrimary}
>
Schedule
</button>
</div>
</div>
</PanelShell>
);
}

View File

@ -41,7 +41,7 @@ type LinkKind = "form" | "microsite" | "custom";
export type EmailerContentBlock = export type EmailerContentBlock =
| { id: string; type: "text"; html: string } | { id: string; type: "text"; html: string }
| { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; micrositeId?: string; customUrl?: string } | { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; formType?: string; micrositeId?: string; customUrl?: string }
| { id: string; type: "image"; src: string } | { id: string; type: "image"; src: string }
| { id: string; type: "divider" }; | { id: string; type: "divider" };
@ -99,17 +99,25 @@ function publicFormHref(formId: string, eventId?: string): string {
function resolveCtaHref( function resolveCtaHref(
b: Extract<EmailerContentBlock, { type: "cta" }>, b: Extract<EmailerContentBlock, { type: "cta" }>,
targets: { forms: { id: string; href: string }[]; microsites: { id: string; href: string }[] }, targets: { forms: { id: string; href: string; type?: string }[]; microsites: { id: string; href: string }[] },
): string { ): string {
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#"; if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
if (b.linkKind === "form") { if (b.linkKind === "form") {
const f = targets.forms.find((x) => x.id === b.formId); if (b.formId) {
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : ""); const f = targets.forms.find((x) => x.id === b.formId);
return href || "#"; const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
if (href) return href;
}
if (b.formType) {
const f = targets.forms.find((x) => x.type === b.formType);
if (f?.href?.trim()) return f.href.trim();
}
return "#";
} }
if (b.linkKind === "microsite") { if (b.linkKind === "microsite") {
const m = targets.microsites.find((x) => x.id === b.micrositeId); const m = targets.microsites.find((x) => x.id === b.micrositeId);
return (m?.href && m.href.trim()) || "#"; if (m?.href?.trim()) return m.href.trim();
return targets.microsites[0]?.href?.trim() || "#";
} }
return "#"; return "#";
} }
@ -195,6 +203,7 @@ function migrateBlocksFromContent(c: Record<string, unknown>): EmailerContentBlo
label: String(b.label ?? b.text ?? "Register"), label: String(b.label ?? b.text ?? "Register"),
linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind, linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind,
formId: typeof b.formId === "string" ? b.formId : undefined, formId: typeof b.formId === "string" ? b.formId : undefined,
formType: typeof b.formType === "string" ? b.formType : undefined,
micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined, micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined,
customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""), customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""),
}); });
@ -250,6 +259,7 @@ export function CreateEmailerPanel({
const [campaignEventId, setCampaignEventId] = useState(""); const [campaignEventId, setCampaignEventId] = useState("");
const [participantListId, setParticipantListId] = useState(""); const [participantListId, setParticipantListId] = useState("");
const [campaignErr, setCampaignErr] = useState(""); const [campaignErr, setCampaignErr] = useState("");
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState(false);
const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => { const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => {
setForm((p) => ({ ...p, [k]: v })); setForm((p) => ({ ...p, [k]: v }));
@ -285,6 +295,7 @@ export function CreateEmailerPanel({
void designTemplatesApi.get(templateId).then((res) => { void designTemplatesApi.get(templateId).then((res) => {
if (!res.data) return; if (!res.data) return;
const t = res.data; const t = res.data;
setEditingGlobalTemplate(Boolean(t.isSystemTemplate));
const c = (t.content || {}) as Record<string, unknown>; const c = (t.content || {}) as Record<string, unknown>;
const migrated = migrateBlocksFromContent(c); const migrated = migrateBlocksFromContent(c);
const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent; const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent;
@ -397,6 +408,7 @@ export function CreateEmailerPanel({
category: form.emailerType || undefined, category: form.emailerType || undefined,
content, content,
associatedEvent: form.associatedEvent.trim() || null, associatedEvent: form.associatedEvent.trim() || null,
organization: listOrgId || undefined,
}; };
const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload); const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload);
@ -437,7 +449,11 @@ export function CreateEmailerPanel({
onClose={onClose} onClose={onClose}
title={isEdit ? "Edit Emailer" : "Create Emailer"} title={isEdit ? "Edit Emailer" : "Create Emailer"}
breadcrumb="Design · Emailers" breadcrumb="Design · Emailers"
description="Design a branded HTML emailer and optional draft campaign." description={
editingGlobalTemplate
? "Global template — saving creates your organization's customized copy."
: "Design a branded HTML emailer and optional draft campaign."
}
completedCount={acc.done.size} completedCount={acc.done.size}
totalSections={TOTAL} totalSections={TOTAL}
ctaLabel={isEdit ? "Save changes" : "Save emailer"} ctaLabel={isEdit ? "Save changes" : "Save emailer"}

View File

@ -429,18 +429,33 @@ export function CreateEventPanel({
disabled={form.registrationRequired !== "Yes"} disabled={form.registrationRequired !== "Yes"}
> >
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option> <option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
<option>Open</option><option>Invite Only</option><option>Approval Required</option> <option>Open</option><option>Invite Only</option>
</select> </select>
</SelectWrap> </SelectWrap>
</Field> </Field>
</Row> </Row>
<Row> <Row>
<Field>
<Label text="Registration Approval" required={form.registrationRequired === "Yes"} />
<RadioGroup
name="registrationApproval"
value={form.registrationApproval}
onChange={v => f("registrationApproval", v as "auto" | "manual")}
options={[
{ label: "Auto Confirm", value: "auto" },
{ label: "Manual Confirm", value: "manual" },
]}
disabled={form.registrationRequired !== "Yes"}
/>
</Field>
<Field> <Field>
<Label text="Verify Email" required /> <Label text="Verify Email" required />
<RadioGroup name="verifyEmail" value={form.verifyEmail} <RadioGroup name="verifyEmail" value={form.verifyEmail}
onChange={v => f("verifyEmail", v as "Yes" | "No")} onChange={v => f("verifyEmail", v as "Yes" | "No")}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} /> options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
</Field> </Field>
</Row>
<Row>
<Field> <Field>
<Label text="Registration Deadline" /> <Label text="Registration Deadline" />
<input type="date" style={IS} value={form.registrationDeadline} onChange={e => f("registrationDeadline", e.target.value)} /> <input type="date" style={IS} value={form.registrationDeadline} onChange={e => f("registrationDeadline", e.target.value)} />

View File

@ -422,6 +422,7 @@ interface MergedForm {
virtualLink: string; virtualLink: string;
registrationRequired: "Yes" | "No"; registrationRequired: "Yes" | "No";
registrationMode: string; registrationMode: string;
registrationApproval: "auto" | "manual";
verifyEmail: "Yes" | "No"; verifyEmail: "Yes" | "No";
registrationDeadline: string; registrationDeadline: string;
authenticationType: string; authenticationType: string;
@ -459,6 +460,7 @@ const INITIAL: MergedForm = {
virtualLink: "", virtualLink: "",
registrationRequired: "No", registrationRequired: "No",
registrationMode: "", registrationMode: "",
registrationApproval: "auto",
verifyEmail: "Yes", verifyEmail: "Yes",
registrationDeadline: "", registrationDeadline: "",
authenticationType: "otp_email", authenticationType: "otp_email",
@ -859,6 +861,7 @@ export function CreateEventSessionPanel({
virtualLink: form.virtualLink, virtualLink: form.virtualLink,
registrationRequired: form.registrationRequired, registrationRequired: form.registrationRequired,
registrationMode: form.registrationMode, registrationMode: form.registrationMode,
registrationApproval: form.registrationApproval,
verifyEmail: form.verifyEmail, verifyEmail: form.verifyEmail,
registrationDeadline: form.registrationDeadline, registrationDeadline: form.registrationDeadline,
authenticationType: form.authenticationType, authenticationType: form.authenticationType,
@ -1593,12 +1596,24 @@ export function CreateEventSessionPanel({
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option> <option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
<option>Open</option> <option>Open</option>
<option>Invite Only</option> <option>Invite Only</option>
<option>Approval Required</option>
</select> </select>
</SelectWrap> </SelectWrap>
</Field> </Field>
</Row> </Row>
<Row> <Row>
<Field>
<Label text="Registration approval" required={form.registrationRequired === "Yes"} />
<RadioGroup
name="registrationApproval"
value={form.registrationApproval}
onChange={v => f("registrationApproval", v as "auto" | "manual")}
options={[
{ label: "Auto Confirm", value: "auto" },
{ label: "Manual Confirm", value: "manual" },
]}
disabled={form.registrationRequired !== "Yes"}
/>
</Field>
<Field> <Field>
<Label text="Verify email" required /> <Label text="Verify email" required />
<RadioGroup <RadioGroup
@ -1611,6 +1626,8 @@ export function CreateEventSessionPanel({
]} ]}
/> />
</Field> </Field>
</Row>
<Row>
<Field> <Field>
<Label text="Registration deadline" /> <Label text="Registration deadline" />
<input <input

View File

@ -7,8 +7,8 @@ import { Plus, Trash2, GripVertical } from "lucide-react";
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell"; import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../hooks/usePermission"; import { usePermission } from "../../hooks/usePermission";
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api"; import { eventsApi, formsApi, participantListsApi, designTemplatesApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord } from "../../../services/api"; import type { Form, FormField, ParticipantListRecord, DesignTemplate } from "../../../services/api";
import { assignUniqueFieldNames } from "../../utils/formFieldNames"; import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import { import {
EVENT_HEADER_TOGGLE_LABELS, EVENT_HEADER_TOGGLE_LABELS,
@ -44,6 +44,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
nextPatch.options = []; nextPatch.options = [];
nextPatch.mapsTo = ""; nextPatch.mapsTo = "";
nextPatch.customMapsKey = ""; nextPatch.customMapsKey = "";
nextPatch.convertToTag = false;
return nextPatch; return nextPatch;
} }
if (OPTION_FIELD_TYPES.has(next)) { if (OPTION_FIELD_TYPES.has(next)) {
@ -55,12 +56,22 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
nextPatch.allowOther = false; nextPatch.allowOther = false;
nextPatch.otherLabel = undefined; nextPatch.otherLabel = undefined;
} }
if (!canConvertValueToTag(next)) {
nextPatch.convertToTag = false;
}
return nextPatch; return nextPatch;
} }
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */ /** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]); const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
/** Choice / selectable fields whose submitted labels can become participant tags. */
const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]);
function canConvertValueToTag(type: string): boolean {
return CONVERT_TO_TAG_FIELD_TYPES.has(type);
}
const FORM_Q_DRAG = "application/x-eventbuzz-form-q"; const FORM_Q_DRAG = "application/x-eventbuzz-form-q";
function reorderList<T>(items: T[], from: number, to: number): T[] { function reorderList<T>(items: T[], from: number, to: number): T[] {
@ -183,6 +194,8 @@ interface Question {
fixed?: boolean; fixed?: boolean;
/** Optional note shown below the field on the public form. */ /** Optional note shown below the field on the public form. */
remarks?: string; remarks?: string;
/** Registration: add submitted value(s) as participant tags. */
convertToTag?: boolean;
} }
function normalizedOptions(q: Question): string[] { function normalizedOptions(q: Question): string[] {
@ -429,6 +442,18 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReord
<HelperText text="Stored under Participant.customFields[key]." /> <HelperText text="Stored under Participant.customFields[key]." />
</Field> </Field>
)} )}
{canConvertValueToTag(q.type) ? (
<Field span2>
<Label text="Convert value to tag" />
<RadioGroup
name={`tag-${q.id}`}
value={q.convertToTag ? "Yes" : "No"}
onChange={(v) => onPatch({ convertToTag: v === "Yes" })}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
/>
<HelperText text="When Yes, the selected option(s) are added as participant tags." />
</Field>
) : null}
</> </>
) : null} ) : null}
{!isSection ? ( {!isSection ? (
@ -452,14 +477,25 @@ interface FForm {
questions: Question[]; questions: Question[];
showThankYou: "Yes" | "No"; showThankYou: "Yes" | "No";
thankYouMsg: string; thankYouMsg: string;
formClosedMessage: string;
submitErrorMessage: string;
captchaFailedMessage: string;
registrationClosedMessage: string;
registrationDeadlineMessage: string;
registrationFullMessage: string;
duplicateEmailMessage: string;
duplicatePhoneMessage: string;
collectEmail: "Yes" | "No"; collectEmail: "Yes" | "No";
anonymous: "Yes" | "No"; anonymous: "Yes" | "No";
publish: "Draft" | "Published"; publish: "Draft" | "Published";
paymentEnabled: "Yes" | "No"; paymentEnabled: "Yes" | "No";
amount: string; amount: string;
confirmationEmail: "Yes" | "No"; confirmationEmail: "Yes" | "No";
confirmationEmailTemplateId: string;
onSubmitTags: string; onSubmitTags: string;
onSubmitListId: string; onSubmitListId: string;
allowDuplicateEmail: "Yes" | "No";
allowDuplicatePhone: "Yes" | "No";
headerShowLogo: "Yes" | "No"; headerShowLogo: "Yes" | "No";
headerShowTitle: "Yes" | "No"; headerShowTitle: "Yes" | "No";
headerShowDateRange: "Yes" | "No"; headerShowDateRange: "Yes" | "No";
@ -468,6 +504,19 @@ interface FForm {
headerShowVenue: "Yes" | "No"; headerShowVenue: "Yes" | "No";
} }
const DEFAULT_FORM_MESSAGES = {
thankYouFeedback: "Thank you for your feedback!",
thankYouResponse: "Thank you for your response!",
formClosed: "This form is not accepting responses.",
submitError: "Submit failed. Please try again.",
captchaFailed: "Captcha verification failed. Please try again.",
registrationClosed: "Registration is closed for this event.",
registrationDeadline: "Registration deadline has passed.",
registrationFull: "This event has reached maximum participants.",
duplicateEmail: "This email is already registered for this event.",
duplicatePhone: "This phone number is already registered for this event.",
} as const;
const newQ = (types: string[]): Question => { const newQ = (types: string[]): Question => {
const first = types[0]; const first = types[0];
return { return {
@ -487,15 +536,26 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
description: "", description: "",
questions: [newQ(qTypes)], questions: [newQ(qTypes)],
showThankYou: "Yes", showThankYou: "Yes",
thankYouMsg: "Thank you for your feedback!", thankYouMsg: isFeedback ? DEFAULT_FORM_MESSAGES.thankYouFeedback : DEFAULT_FORM_MESSAGES.thankYouResponse,
formClosedMessage: DEFAULT_FORM_MESSAGES.formClosed,
submitErrorMessage: DEFAULT_FORM_MESSAGES.submitError,
captchaFailedMessage: DEFAULT_FORM_MESSAGES.captchaFailed,
registrationClosedMessage: DEFAULT_FORM_MESSAGES.registrationClosed,
registrationDeadlineMessage: DEFAULT_FORM_MESSAGES.registrationDeadline,
registrationFullMessage: DEFAULT_FORM_MESSAGES.registrationFull,
duplicateEmailMessage: DEFAULT_FORM_MESSAGES.duplicateEmail,
duplicatePhoneMessage: DEFAULT_FORM_MESSAGES.duplicatePhone,
collectEmail: "No", collectEmail: "No",
anonymous: isFeedback ? "Yes" : "No", anonymous: isFeedback ? "Yes" : "No",
publish: "Draft", publish: "Draft",
paymentEnabled: "No", paymentEnabled: "No",
amount: "", amount: "",
confirmationEmail: "Yes", confirmationEmail: "No",
confirmationEmailTemplateId: "",
onSubmitTags: "", onSubmitTags: "",
onSubmitListId: "", onSubmitListId: "",
allowDuplicateEmail: "Yes",
allowDuplicatePhone: "Yes",
headerShowLogo: "Yes", headerShowLogo: "Yes",
headerShowTitle: "Yes", headerShowTitle: "Yes",
headerShowDateRange: "Yes", headerShowDateRange: "Yes",
@ -627,6 +687,28 @@ function toApiField(q: Question, mode: FormMode): FormField {
} }
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType)); return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
} }
// Registration / web-check-in: also absorb feedback-style labels that may linger in legacy forms.
if (q.type === "Star Rating") {
return withFieldRemarks({ label, name, type: "stars", required, options: ["1", "2", "3", "4", "5"] }, q.remarks);
}
if (q.type === "NPS (0–10)") {
return withFieldRemarks({
label,
name,
type: "select",
required,
options: Array.from({ length: 11 }, (_, i) => String(i)),
}, q.remarks);
}
if (q.type === "Multiple Choice" || q.type === "Short Text" || q.type === "Long Text") {
if (q.type === "Multiple Choice") {
return withFieldRemarks({ label, name, type: "select", required, options: choiceOpts }, q.remarks);
}
if (q.type === "Long Text") {
return withFieldRemarks({ label, name, type: "textarea", required }, q.remarks);
}
return withFieldRemarks({ label, name, type: "text", required }, q.remarks);
}
const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"]; const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"];
if (type === "select" || type === "radio" || type === "checkboxes") { if (type === "select" || type === "radio" || type === "checkboxes") {
const base = { label, name, type, required, options: choiceOpts }; const base = { label, name, type, required, options: choiceOpts };
@ -648,6 +730,7 @@ function toApiFieldReg(q: Question): FormField {
mapsTo: q.mapsTo || undefined, mapsTo: q.mapsTo || undefined,
customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined, customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined,
isFixed: q.fixed === true, isFixed: q.fixed === true,
convertToTag: canConvertValueToTag(q.type) && q.convertToTag === true,
}; };
} }
@ -673,6 +756,7 @@ function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode:
customMapsKey: f.customMapsKey, customMapsKey: f.customMapsKey,
fixed: f.isFixed, fixed: f.isFixed,
remarks: f.remarks ?? "", remarks: f.remarks ?? "",
convertToTag: f.convertToTag === true,
}; };
}); });
return assignUniqueFieldNames(questions); return assignUniqueFieldNames(questions);
@ -693,6 +777,7 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
customMapsKey: f.customMapsKey, customMapsKey: f.customMapsKey,
fixed: f.isFixed === true, fixed: f.isFixed === true,
remarks: f.remarks ?? "", remarks: f.remarks ?? "",
convertToTag: f.convertToTag === true,
}; };
} }
@ -730,7 +815,7 @@ export function CreateFormPanel({
const isOther = mode === "other"; const isOther = mode === "other";
const isWebCheckIn = mode === "web-check-in"; const isWebCheckIn = mode === "web-check-in";
const TOTAL = isReg ? 5 : 3; const TOTAL = isReg ? 5 : 3;
const qTypes = isReg || isWebCheckIn ? OTHER_Q_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES; const qTypes = isReg || isWebCheckIn ? REG_FIELD_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn; const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn;
const acc = useAccordion(TOTAL); const acc = useAccordion(TOTAL);
@ -741,6 +826,7 @@ export function CreateFormPanel({
return base; return base;
}); });
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]); const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [emailerTemplates, setEmailerTemplates] = useState<DesignTemplate[]>([]);
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({}); const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(!!formId); const [loading, setLoading] = useState(!!formId);
@ -788,6 +874,27 @@ export function CreateFormPanel({
}).catch(() => setFixedLists([])); }).catch(() => setFixedLists([]));
}, [isReg, listOrgId]); }, [isReg, listOrgId]);
useEffect(() => {
if (!isReg) return;
if (!listOrgId) {
setEmailerTemplates([]);
return;
}
let cancelled = false;
void designTemplatesApi
.list({ type: "emailer", organization: listOrgId, limit: 200 })
.then((r) => {
if (cancelled) return;
setEmailerTemplates(r.data?.templates ?? []);
})
.catch(() => {
if (!cancelled) setEmailerTemplates([]);
});
return () => {
cancelled = true;
};
}, [isReg, listOrgId]);
useEffect(() => { useEffect(() => {
if (!formId) return; if (!formId) return;
let cancelled = false; let cancelled = false;
@ -808,15 +915,30 @@ export function CreateFormPanel({
description: data.description ?? "", description: data.description ?? "",
questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]), questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]),
showThankYou: s.showThankYou === false ? "No" : "Yes", showThankYou: s.showThankYou === false ? "No" : "Yes",
thankYouMsg: s.thankYouMessage?.trim() ? s.thankYouMessage : "Thank you for your feedback!", thankYouMsg: s.thankYouMessage?.trim()
? s.thankYouMessage
: (isFeedbackOrOther ? DEFAULT_FORM_MESSAGES.thankYouFeedback : DEFAULT_FORM_MESSAGES.thankYouResponse),
formClosedMessage: s.formClosedMessage?.trim() || DEFAULT_FORM_MESSAGES.formClosed,
submitErrorMessage: s.submitErrorMessage?.trim() || DEFAULT_FORM_MESSAGES.submitError,
captchaFailedMessage: s.captchaFailedMessage?.trim() || DEFAULT_FORM_MESSAGES.captchaFailed,
registrationClosedMessage: s.registrationClosedMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationClosed,
registrationDeadlineMessage: s.registrationDeadlineMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationDeadline,
registrationFullMessage: s.registrationFullMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationFull,
duplicateEmailMessage: s.duplicateEmailMessage?.trim() || DEFAULT_FORM_MESSAGES.duplicateEmail,
duplicatePhoneMessage: s.duplicatePhoneMessage?.trim() || DEFAULT_FORM_MESSAGES.duplicatePhone,
collectEmail: s.collectEmail ? "Yes" : "No", collectEmail: s.collectEmail ? "Yes" : "No",
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"), anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
publish: s.isActive !== false ? "Published" : "Draft", publish: s.isActive !== false ? "Published" : "Draft",
paymentEnabled: s.paymentEnabled ? "Yes" : "No", paymentEnabled: s.paymentEnabled ? "Yes" : "No",
amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "", amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "",
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes", confirmationEmail: s.sendConfirmationEmail === true ? "Yes" : "No",
confirmationEmailTemplateId: s.confirmationEmailTemplateId
? String(s.confirmationEmailTemplateId)
: "",
onSubmitTags: tags, onSubmitTags: tags,
onSubmitListId: s.onSubmitParticipantListId ?? "", onSubmitListId: s.onSubmitParticipantListId ?? "",
allowDuplicateEmail: s.allowDuplicateEmail === false ? "No" : "Yes",
allowDuplicatePhone: s.allowDuplicatePhone === false ? "No" : "Yes",
headerShowLogo: header.showLogo, headerShowLogo: header.showLogo,
headerShowTitle: header.showTitle, headerShowTitle: header.showTitle,
headerShowDateRange: header.showDateRange, headerShowDateRange: header.showDateRange,
@ -876,7 +998,11 @@ export function CreateFormPanel({
return anyLabel && choicesOk; return anyLabel && choicesOk;
} }
if (n === 3 && isReg) return true; if (n === 3 && isReg) return true;
if (n === 4 && isReg) return true; if (n === 4 && isReg) {
if (f.confirmationEmail === "Yes" && !f.confirmationEmailTemplateId.trim()) return false;
if (f.paymentEnabled === "Yes" && !f.amount.trim()) return false;
return true;
}
if (n === (isReg ? 5 : 3)) return true; if (n === (isReg ? 5 : 3)) return true;
return true; return true;
}, [isReg, isWebCheckIn, selectedEventId]); }, [isReg, isWebCheckIn, selectedEventId]);
@ -910,6 +1036,13 @@ export function CreateFormPanel({
paymentEnabled: form.paymentEnabled === "Yes", paymentEnabled: form.paymentEnabled === "Yes",
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "", paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
sendConfirmationEmail: form.confirmationEmail === "Yes", sendConfirmationEmail: form.confirmationEmail === "Yes",
confirmationEmailTemplateId:
form.confirmationEmail === "Yes" && form.confirmationEmailTemplateId.trim()
? form.confirmationEmailTemplateId.trim()
: null,
formClosedMessage: form.formClosedMessage.trim(),
submitErrorMessage: form.submitErrorMessage.trim(),
captchaFailedMessage: form.captchaFailedMessage.trim(),
eventHeader: eventHeaderToApi({ eventHeader: eventHeaderToApi({
showLogo: form.headerShowLogo, showLogo: form.headerShowLogo,
showTitle: form.headerShowTitle, showTitle: form.headerShowTitle,
@ -921,7 +1054,14 @@ export function CreateFormPanel({
...(isReg ...(isReg
? { ? {
onSubmitAddTags: tagArr, onSubmitAddTags: tagArr,
onSubmitParticipantListId: form.onSubmitListId.trim() || null, onSubmitParticipantListId: form.onSubmitListId?.trim() || null,
allowDuplicateEmail: form.allowDuplicateEmail !== "No",
allowDuplicatePhone: form.allowDuplicatePhone !== "No",
registrationClosedMessage: form.registrationClosedMessage.trim(),
registrationDeadlineMessage: form.registrationDeadlineMessage.trim(),
registrationFullMessage: form.registrationFullMessage.trim(),
duplicateEmailMessage: form.duplicateEmailMessage.trim(),
duplicatePhoneMessage: form.duplicatePhoneMessage.trim(),
} }
: {}), : {}),
}, },
@ -944,8 +1084,9 @@ export function CreateFormPanel({
} }
onSuccess(); onSuccess();
onClose(); onClose();
} catch { } catch (e) {
setSectionErrors(prev => ({ ...prev, 1: "Failed to save. Please try again." })); const msg = e instanceof Error && e.message.trim() ? e.message.trim() : "Failed to save. Please try again.";
setSectionErrors((prev) => ({ ...prev, 1: msg }));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -1118,7 +1259,7 @@ export function CreateFormPanel({
<Field> <Field>
<Label text="Add tags on submit" /> <Label text="Add tags on submit" />
<input style={IS} placeholder="e.g. VIP, Registered — comma separated" value={form.onSubmitTags} onChange={e => set("onSubmitTags", e.target.value)} onFocus={focusIn} onBlur={focusOut} /> <input style={IS} placeholder="e.g. VIP, Registered — comma separated" value={form.onSubmitTags} onChange={e => set("onSubmitTags", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
<HelperText text="Merged onto the participant record (deduplicated)." /> <HelperText text="Merged onto the participant record (deduplicated). Selectable fields (Dropdown, Radio, Checkboxes, etc.) can also add tags via “Convert value to tag”." />
</Field> </Field>
<Field> <Field>
<Label text="Add to fixed participant list" /> <Label text="Add to fixed participant list" />
@ -1132,6 +1273,28 @@ export function CreateFormPanel({
</div> </div>
<HelperText text="Only fixed (manual) lists in your organization are shown." /> <HelperText text="Only fixed (manual) lists in your organization are shown." />
</Field> </Field>
<Grid cols={2}>
<Field>
<Label text="Allow duplicate email" required />
<RadioGroup
name="allowDupEmail"
value={form.allowDuplicateEmail}
onChange={v => set("allowDuplicateEmail", v as FForm["allowDuplicateEmail"])}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
/>
<HelperText text="Case-insensitive. When No, emails already registered for this event are rejected." />
</Field>
<Field>
<Label text="Allow duplicate phone" required />
<RadioGroup
name="allowDupPhone"
value={form.allowDuplicatePhone}
onChange={v => set("allowDuplicatePhone", v as FForm["allowDuplicatePhone"])}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
/>
<HelperText text="Compares the last 10 digits only (ignores +, spaces, country code)." />
</Field>
</Grid>
</AccordionSection> </AccordionSection>
)} )}
@ -1160,9 +1323,50 @@ export function CreateFormPanel({
)} )}
<Field> <Field>
<Label text="Confirmation Email" required /> <Label text="Confirmation Email" required />
<RadioGroup name="confEmail" value={form.confirmationEmail} onChange={v => set("confirmationEmail", v as FForm["confirmationEmail"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} /> <RadioGroup
<HelperText text="Send a confirmation email after registration." /> name="confEmail"
value={form.confirmationEmail}
onChange={(v) => {
const next = v as FForm["confirmationEmail"];
if (next === "Yes") {
const preferred =
emailerTemplates.find((t) => t.systemTemplateKey === "register_success")?._id
|| emailerTemplates.find((t) => t.systemTemplateKey === "register_confirmation")?._id
|| emailerTemplates[0]?._id
|| "";
setForm((prev) => ({
...prev,
confirmationEmail: next,
confirmationEmailTemplateId: prev.confirmationEmailTemplateId || preferred,
}));
} else {
set("confirmationEmail", next);
}
}}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
/>
<HelperText text="When Yes, send an emailer template after a successful registration." />
</Field> </Field>
{form.confirmationEmail === "Yes" && (
<Field span2>
<Label text="Confirmation email template" required />
<select
style={SS}
value={form.confirmationEmailTemplateId}
onChange={(e) => set("confirmationEmailTemplateId", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select an email template…</option>
{emailerTemplates.map((t) => (
<option key={t._id} value={t._id}>
{t.name}{t.isSystemTemplate || t.isGlobalTemplate ? " (system)" : ""}{t.category ? ` · ${t.category}` : ""}
</option>
))}
</select>
<HelperText text={emailerTemplates.length ? "Choose from Design → Emailers (system and organisation templates)." : "No emailer templates found. Create one under Design → Emailers."} />
</Field>
)}
</Grid> </Grid>
</AccordionSection> </AccordionSection>
)} )}
@ -1170,7 +1374,7 @@ export function CreateFormPanel({
<AccordionSection <AccordionSection
n={isReg ? 5 : 3} n={isReg ? 5 : 3}
title="Publish Settings" title="Publish Settings"
subtitle="Set the form status and thank-you message" subtitle="Status and respondent-facing messages"
open={acc.open === (isReg ? 5 : 3)} open={acc.open === (isReg ? 5 : 3)}
completed={acc.done.has(isReg ? 5 : 3)} completed={acc.done.has(isReg ? 5 : 3)}
onToggle={() => acc.toggle(isReg ? 5 : 3)} onToggle={() => acc.toggle(isReg ? 5 : 3)}
@ -1188,12 +1392,129 @@ export function CreateFormPanel({
</select> </select>
</div> </div>
</Field> </Field>
<Field>
<Label text="Show thank-you screen" required />
<RadioGroup
name="showThankYou"
value={form.showThankYou}
onChange={(v) => set("showThankYou", v as FForm["showThankYou"])}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
/>
</Field>
<Field span2> <Field span2>
<Label text="Thank You Message" /> <Label text="Success / thank-you message" />
<input style={IS} placeholder="Thank you for your response!" value={form.thankYouMsg} onChange={e => set("thankYouMsg", e.target.value)} onFocus={focusIn} onBlur={focusOut} /> <textarea
<HelperText text="Shown to respondents after they submit the form." /> style={{ ...IS, resize: "vertical", minHeight: "64px" }}
placeholder={DEFAULT_FORM_MESSAGES.thankYouResponse}
value={form.thankYouMsg}
onChange={e => set("thankYouMsg", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Shown after a successful submission." />
</Field>
<Field span2>
<Label text="Form closed message" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.formClosed}
value={form.formClosedMessage}
onChange={e => set("formClosedMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Shown when the form is Draft / not accepting responses." />
</Field>
<Field span2>
<Label text="Generic error message" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.submitError}
value={form.submitErrorMessage}
onChange={e => set("submitErrorMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Fallback when submit fails without a more specific API message." />
</Field>
<Field span2>
<Label text="Captcha failed message" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.captchaFailed}
value={form.captchaFailedMessage}
onChange={e => set("captchaFailedMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field> </Field>
</Grid> </Grid>
{isReg ? (
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
<p style={{ margin: 0, fontSize: "var(--text-xs)", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)" }}>
Registration messages
</p>
<Field>
<Label text="Registration closed" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.registrationClosed}
value={form.registrationClosedMessage}
onChange={e => set("registrationClosedMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Registration deadline passed" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.registrationDeadline}
value={form.registrationDeadlineMessage}
onChange={e => set("registrationDeadlineMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Registration full" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.registrationFull}
value={form.registrationFullMessage}
onChange={e => set("registrationFullMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Duplicate email" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.duplicateEmail}
value={form.duplicateEmailMessage}
onChange={e => set("duplicateEmailMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Used when “Allow duplicate email” is No." />
</Field>
<Field>
<Label text="Duplicate phone" />
<textarea
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
placeholder={DEFAULT_FORM_MESSAGES.duplicatePhone}
value={form.duplicatePhoneMessage}
onChange={e => set("duplicatePhoneMessage", e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
/>
<HelperText text="Used when “Allow duplicate phone” is No." />
</Field>
</div>
) : null}
<Field> <Field>
<Label text="Event header on public form" /> <Label text="Event header on public form" />
<HelperText text="Choose which linked event details appear above the form on the public page." /> <HelperText text="Choose which linked event details appear above the form on the public page." />

View File

@ -121,6 +121,18 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
setPage(1); setPage(1);
}; };
const hasActiveFilters = Boolean(q.trim() || dateFrom || dateTo || appliedQ || appliedFrom || appliedTo);
const clearFilters = () => {
setQ("");
setDateFrom("");
setDateTo("");
setAppliedQ("");
setAppliedFrom("");
setAppliedTo("");
setPage(1);
};
const onExport = async () => { const onExport = async () => {
setExporting(true); setExporting(true);
try { try {
@ -383,6 +395,25 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
> >
Apply Apply
</button> </button>
<button
type="button"
onClick={clearFilters}
disabled={!hasActiveFilters}
style={{
padding: "8px 14px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
color: "var(--muted)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
cursor: hasActiveFilters ? "pointer" : "not-allowed",
opacity: hasActiveFilters ? 1 : 0.5,
}}
>
Clear
</button>
<button <button
type="button" type="button"
onClick={() => void onExport()} onClick={() => void onExport()}

View File

@ -0,0 +1,227 @@
import React, { useEffect, useMemo, useState } from "react";
import { websiteSettingsApi } from "../../../services/api/websiteSettings";
import type { WebsiteThemeConfig, WebsiteThemeMode } from "../../../services/api/types";
import {
Field,
HelperText,
Label,
PanelShell,
SelectWrap,
SS,
focusIn,
focusOut,
} from "./PanelShell";
const THEME_OPTIONS: { value: WebsiteThemeMode; label: string }[] = [
{ value: "simple", label: "Simple" },
{ value: "gradient", label: "Gradient" },
];
const SIMPLE_COLORS = [
{ value: "blue", label: "Blue (default)" },
{ value: "green", label: "Green" },
{ value: "yellow", label: "Yellow" },
{ value: "purple", label: "Purple" },
{ value: "gray", label: "Gray" },
] as const;
const GRADIENT_COLORS = [
{ value: "green", label: "Green" },
{ value: "warm", label: "Warm" },
{ value: "warm2", label: "Warm 2" },
] as const;
const FONT_OPTIONS = ["Raleway", "Montserrat", "Open Sans", "Roboto", "Poppins"] as const;
const DEFAULT_THEME: WebsiteThemeConfig = {
theme: "simple",
color: "blue",
font: "Open Sans",
};
function normalizeTheme(raw?: WebsiteThemeConfig | null): WebsiteThemeConfig {
const mode: WebsiteThemeMode = raw?.theme === "gradient" ? "gradient" : "simple";
const fonts = new Set<string>(FONT_OPTIONS);
const fontRaw = raw?.font != null ? String(raw.font).trim() : "";
const font = fontRaw && fonts.has(fontRaw) ? fontRaw : DEFAULT_THEME.font!;
let color = raw?.color != null ? String(raw.color).trim().toLowerCase() : "";
if (mode === "simple") {
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
if (!ok.has(color as (typeof SIMPLE_COLORS)[number]["value"])) color = "blue";
} else {
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
if (!ok.has(color as (typeof GRADIENT_COLORS)[number]["value"])) color = "green";
}
return { theme: mode, color, font };
}
export function WebsiteThemeSettingsPanel({
eventId,
onClose,
onSuccess,
}: {
eventId: string;
onClose: () => void;
onSuccess?: () => void;
}) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [themeMode, setThemeMode] = useState<WebsiteThemeMode>(DEFAULT_THEME.theme);
const [color, setColor] = useState(DEFAULT_THEME.color);
const [font, setFont] = useState(DEFAULT_THEME.font || "Open Sans");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
websiteSettingsApi
.get(eventId)
.then((res) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
return;
}
const t = normalizeTheme(res.data?.theme);
setThemeMode(t.theme);
setColor(t.color);
setFont(t.font || "Open Sans");
})
.catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "Could not load website settings.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [eventId]);
const colorOptions = useMemo(
() => (themeMode === "gradient" ? GRADIENT_COLORS : SIMPLE_COLORS),
[themeMode],
);
const onThemeModeChange = (next: WebsiteThemeMode) => {
setThemeMode(next);
if (next === "gradient") {
const ok = new Set(GRADIENT_COLORS.map((c) => c.value));
if (!ok.has(color as (typeof GRADIENT_COLORS)[number]["value"])) setColor("green");
} else {
const ok = new Set(SIMPLE_COLORS.map((c) => c.value));
if (!ok.has(color as (typeof SIMPLE_COLORS)[number]["value"])) setColor("blue");
}
};
const save = async () => {
setSaving(true);
setError(null);
try {
const theme = normalizeTheme({ theme: themeMode, color, font });
const res = await websiteSettingsApi.update(eventId, { theme });
if (res.error) throw new Error(res.error);
const saved = normalizeTheme(res.data?.theme ?? theme);
setThemeMode(saved.theme);
setColor(saved.color);
setFont(saved.font || "Open Sans");
onSuccess?.();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Could not save website theme.");
} finally {
setSaving(false);
}
};
return (
<PanelShell
title="Website settings"
breadcrumb="Event Home"
description="Choose the look of the public event website (theme, color, and font)."
completedCount={1}
totalSections={1}
showProgress={false}
ctaLabel={saving ? "Saving…" : "Save"}
ctaDisabled={loading || saving}
onCtaClick={() => void save()}
onClose={onClose}
panelWidth="min(480px, 100vw)"
>
<div style={{ padding: "20px" }}>
{loading ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
Loading…
</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<Field>
<Label text="Theme" required />
<SelectWrap>
<select
value={themeMode}
onChange={(e) => onThemeModeChange(e.target.value as WebsiteThemeMode)}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
>
{THEME_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
<HelperText text="Simple uses a flat color look. Gradient uses a richer background preset." />
</Field>
<Field>
<Label text="Color" required />
<SelectWrap>
<select
value={color}
onChange={(e) => setColor(e.target.value)}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
>
{colorOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</SelectWrap>
</Field>
<Field>
<Label text="Font" required />
<SelectWrap>
<select
value={font}
onChange={(e) => setFont(e.target.value)}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
>
{FONT_OPTIONS.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</SelectWrap>
</Field>
{error ? (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: 0 }}>
{error}
</p>
) : null}
</div>
)}
</div>
</PanelShell>
);
}

View File

@ -2,8 +2,8 @@
* Map a saved BadgeBatch (config) into CreateBadgePanel form state. * Map a saved BadgeBatch (config) into CreateBadgePanel form state.
*/ */
import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches"; import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches";
import type { BadgeLayoutElement } from "./badgeLayout"; import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import { defaultLayoutForFields } from "./badgeLayout"; import { defaultBackgroundImageLayout, defaultLayoutForFields, parseBackgroundImageLayout } from "./badgeLayout";
export type BadgeFormState = { export type BadgeFormState = {
formName: string; formName: string;
@ -19,6 +19,7 @@ export type BadgeFormState = {
backgroundMode: "image" | "color"; backgroundMode: "image" | "color";
backgroundColor: string; backgroundColor: string;
backgroundImageUrl: string; backgroundImageUrl: string;
backgroundImageLayout: BadgeBackgroundImageLayout;
enabledFields: string[]; enabledFields: string[];
layoutElements: BadgeLayoutElement[]; layoutElements: BadgeLayoutElement[];
customTexts: { id: string; text: string }[]; customTexts: { id: string; text: string }[];
@ -98,8 +99,12 @@ function customImagesFromConfig(c: Record<string, unknown>, layout: BadgeLayoutE
export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState { export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState {
const c = asConfig(batch); const c = asConfig(batch);
const bgMode = str(c.backgroundMode, "color") === "image" ? "image" : "color";
const bgColor = str(c.backgroundColor, "#0B4DD1"); const bgColor = str(c.backgroundColor, "#0B4DD1");
const bgImageUrl = str(c.backgroundImageUrl, "");
const bgMode = bgImageUrl.trim() ? "image" : str(c.backgroundMode, "color") === "image" ? "image" : "color";
const backgroundImageLayout =
parseBackgroundImageLayout(c.backgroundImageLayout) ??
(bgImageUrl.trim() ? defaultBackgroundImageLayout() : defaultBackgroundImageLayout());
const fields = Array.isArray(c.fields) ? c.fields.map((f) => String(f)) : ["name", "title", "org", "qr"]; const fields = Array.isArray(c.fields) ? c.fields.map((f) => String(f)) : ["name", "title", "org", "qr"];
let layoutElements = layoutFromConfig(c); let layoutElements = layoutFromConfig(c);
if (!layoutElements.length && fields.length) { if (!layoutElements.length && fields.length) {
@ -125,7 +130,8 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
participantListId: c.participantListId != null ? String(c.participantListId) : "", participantListId: c.participantListId != null ? String(c.participantListId) : "",
backgroundMode: bgMode, backgroundMode: bgMode,
backgroundColor: bgColor, backgroundColor: bgColor,
backgroundImageUrl: str(c.backgroundImageUrl, ""), backgroundImageUrl: bgImageUrl,
backgroundImageLayout,
enabledFields: fields, enabledFields: fields,
layoutElements, layoutElements,
customTexts, customTexts,

View File

@ -8,6 +8,59 @@ export type BadgeTextAlign = "left" | "center" | "right";
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image"; export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
export type BadgeBackgroundImageLayout = {
x: number;
y: number;
w: number;
h: number;
};
/** Preview selection id for the optional background image layer. */
export const BADGE_BACKGROUND_IMAGE_ID = "__badge_bg__";
const BG_IMAGE_MIN_SIZE = 10;
const BG_IMAGE_MAX_SIZE = 500;
const BG_IMAGE_MIN_POS = -400;
const BG_IMAGE_MAX_POS = 400;
export function defaultBackgroundImageLayout(): BadgeBackgroundImageLayout {
return { x: 0, y: 0, w: 100, h: 100 };
}
export function parseBackgroundImageLayout(raw: unknown): BadgeBackgroundImageLayout | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
if (
typeof o.x !== "number" ||
typeof o.y !== "number" ||
typeof o.w !== "number" ||
typeof o.h !== "number"
) {
return null;
}
return applyBackgroundImageLayoutPatch({ x: o.x, y: o.y, w: o.w, h: o.h });
}
/** Allow bleed outside the badge — only soft sanity limits for save/drag. */
export function applyBackgroundImageLayoutPatch(
layout: BadgeBackgroundImageLayout,
patch: Partial<BadgeBackgroundImageLayout> = {},
): BadgeBackgroundImageLayout {
const w = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.w ?? layout.w));
const h = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.h ?? layout.h));
const x = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.x ?? layout.x));
const y = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.y ?? layout.y));
return { x, y, w, h };
}
/** @deprecated Use applyBackgroundImageLayoutPatch */
export function clampBackgroundImageLayout(
layout: BadgeBackgroundImageLayout,
patch: Partial<BadgeBackgroundImageLayout> = {},
): BadgeBackgroundImageLayout {
return applyBackgroundImageLayoutPatch(layout, patch);
}
export type BadgeLayoutElement = { export type BadgeLayoutElement = {
id: string; id: string;
kind: BadgeLayoutKind; kind: BadgeLayoutKind;
@ -65,6 +118,12 @@ export function elementSizePct(el: BadgeLayoutElement): number {
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18); return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
} }
/** Square elements sized by width % of badge — height as % of badge height */
export function squareElementHeightPct(widthPct: number, badgeAspectRatio: number): number {
const ar = badgeAspectRatio > 0 ? badgeAspectRatio : 1;
return widthPct * ar;
}
export function clampElementSize(el: BadgeLayoutElement, w: number): number { export function clampElementSize(el: BadgeLayoutElement, w: number): number {
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w)); return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
} }
@ -106,28 +165,24 @@ export function applyElementSize(
const size = clampElementSize(el, w); const size = clampElementSize(el, w);
const patch: Partial<BadgeLayoutElement> = { w: size }; const patch: Partial<BadgeLayoutElement> = { w: size };
if (isSquareResizable(el) || el.field === "photo") { if (isSquareResizable(el) || el.field === "photo") {
patch.h = size; patch.h = squareElementHeightPct(size, badgeAspectRatio);
} else if (el.kind === "custom_image") { } else if (el.kind === "custom_image") {
const ar = el.imageAspectRatio ?? 1; const ar = el.imageAspectRatio ?? 1;
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio); patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
} }
const merged = { ...el, ...patch }; const merged = { ...el, ...patch };
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y) }; return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) };
} }
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */ /** Size in % used for drag clamping (w=0 → no horizontal clamp) */
export function elementBoundsPct(el: BadgeLayoutElement): { w: number; h: number } { export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } {
if (el.field === "qr") { if (el.field === "qr" || el.field === "photo") {
const s = elementSizePct(el); const s = elementSizePct(el);
return { w: s, h: s }; return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) };
} }
if (el.kind === "custom_image") { if (el.kind === "custom_image") {
return { w: el.w ?? 25, h: el.h ?? 25 }; 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 (isTextElement(el)) {
if (usesFullWidthCenter(el)) { if (usesFullWidthCenter(el)) {
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) }; return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
@ -141,8 +196,9 @@ export function clampElementPosition(
el: BadgeLayoutElement, el: BadgeLayoutElement,
x: number, x: number,
y: number, y: number,
badgeAspectRatio = 1,
): { x: number; y: number } { ): { x: number; y: number } {
const bounds = elementBoundsPct(el); const bounds = elementBoundsPct(el, badgeAspectRatio);
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100; const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100; const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
let cx = Math.min(maxX, Math.max(0, x)); let cx = Math.min(maxX, Math.max(0, x));

View File

@ -25,6 +25,8 @@ export interface EventCreateFormInput {
virtualLink: string; virtualLink: string;
registrationRequired: "Yes" | "No"; registrationRequired: "Yes" | "No";
registrationMode: string; registrationMode: string;
/** Auto Confirm → requiresApproval false; Manual Confirm → requiresApproval true. */
registrationApproval?: "auto" | "manual";
verifyEmail: "Yes" | "No"; verifyEmail: "Yes" | "No";
registrationDeadline: string; registrationDeadline: string;
authenticationType: string; authenticationType: string;
@ -310,12 +312,14 @@ export interface BuildEventCreatePayloadOpts {
/** Body shape for POST/PUT /events (matches backend Joi + dashboard usage). */ /** Body shape for POST/PUT /events (matches backend Joi + dashboard usage). */
export function buildEventCreatePayload(form: EventCreateFormInput, opts?: BuildEventCreatePayloadOpts): Record<string, unknown> { export function buildEventCreatePayload(form: EventCreateFormInput, opts?: BuildEventCreatePayloadOpts): Record<string, unknown> {
const regReq = form.registrationRequired === "Yes"; const regReq = form.registrationRequired === "Yes";
const mode = form.registrationMode; let mode = form.registrationMode;
// Legacy value — never persist again; approval is a separate control
if (mode === "Approval Required") mode = "Open";
let registrationOpen = true; let registrationOpen = true;
let requiresApproval = false; let requiresApproval = false;
if (regReq) { if (regReq) {
if (mode === "Invite Only") registrationOpen = false; if (mode === "Invite Only") registrationOpen = false;
if (mode === "Approval Required") requiresApproval = true; requiresApproval = form.registrationApproval === "manual";
} else { } else {
registrationOpen = false; registrationOpen = false;
} }

View File

@ -62,14 +62,16 @@ export function RadioGroup({
value, value,
onChange, onChange,
options, options,
disabled,
}: { }: {
name: string; name: string;
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
options: { label: string; value: string }[]; options: { label: string; value: string }[];
disabled?: boolean;
}) { }) {
return ( return (
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap", opacity: disabled ? 0.55 : 1 }}>
{options.map((o) => ( {options.map((o) => (
<label <label
key={o.value} key={o.value}
@ -77,7 +79,7 @@ export function RadioGroup({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "6px", gap: "6px",
cursor: "pointer", cursor: disabled ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)", fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)", fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)", color: "var(--card-foreground)",
@ -88,8 +90,9 @@ export function RadioGroup({
name={name} name={name}
value={o.value} value={o.value}
checked={value === o.value} checked={value === o.value}
disabled={disabled}
onChange={() => onChange(o.value)} onChange={() => onChange(o.value)}
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: "pointer" }} style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: disabled ? "not-allowed" : "pointer" }}
/> />
{o.label} {o.label}
</label> </label>

View File

@ -56,6 +56,8 @@ export interface EventForm {
endDate: string; endDate: string;
registrationRequired: "Yes" | "No"; registrationRequired: "Yes" | "No";
registrationMode: string; registrationMode: string;
/** Auto Confirm → confirmed; Manual Confirm → pending until approved. */
registrationApproval: "auto" | "manual";
verifyEmail: "Yes" | "No"; verifyEmail: "Yes" | "No";
registrationDeadline: string; registrationDeadline: string;
authenticationType: string; authenticationType: string;
@ -91,6 +93,7 @@ export const INITIAL: EventForm = {
endDate: "", endDate: "",
registrationRequired: "No", registrationRequired: "No",
registrationMode: "", registrationMode: "",
registrationApproval: "auto",
verifyEmail: "Yes", verifyEmail: "Yes",
registrationDeadline: "", registrationDeadline: "",
authenticationType: "otp_email", authenticationType: "otp_email",
@ -155,12 +158,16 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
let registrationRequired: "Yes" | "No" = "No"; let registrationRequired: "Yes" | "No" = "No";
let registrationMode = ""; let registrationMode = "";
const mode = st.registrationMode as string | undefined; const mode = st.registrationMode as string | undefined;
const registrationApproval: "auto" | "manual" =
st.requiresApproval === true || mode === "Approval Required" ? "manual" : "auto";
if (mode === "Invite Only") { if (mode === "Invite Only") {
registrationRequired = "Yes"; registrationRequired = "Yes";
registrationMode = "Invite Only"; registrationMode = "Invite Only";
} else if (mode === "Approval Required" || st.requiresApproval === true) { } else if (mode === "Approval Required") {
// Legacy: approval moved to registrationApproval; normalize mode to Open
registrationRequired = "Yes"; registrationRequired = "Yes";
registrationMode = "Approval Required"; registrationMode = "Open";
} else if (mode === "Open" || st.registrationOpen === true) { } else if (mode === "Open" || st.registrationOpen === true) {
registrationRequired = "Yes"; registrationRequired = "Yes";
registrationMode = "Open"; registrationMode = "Open";
@ -168,7 +175,7 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
registrationRequired = "No"; registrationRequired = "No";
} else if (st.registrationOpen || st.requiresApproval) { } else if (st.registrationOpen || st.requiresApproval) {
registrationRequired = "Yes"; registrationRequired = "Yes";
registrationMode = st.requiresApproval ? "Approval Required" : "Open"; registrationMode = "Open";
} }
const meta = (ev.metadata as Record<string, unknown>) || {}; const meta = (ev.metadata as Record<string, unknown>) || {};
@ -210,6 +217,7 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
endDate: toDateInput(ev.endDate as string), endDate: toDateInput(ev.endDate as string),
registrationRequired, registrationRequired,
registrationMode, registrationMode,
registrationApproval,
verifyEmail: st.verifyEmail === false ? "No" : "Yes", verifyEmail: st.verifyEmail === false ? "No" : "Yes",
registrationDeadline: st.registrationDeadline ? toDateInput(st.registrationDeadline as string) : "", registrationDeadline: st.registrationDeadline ? toDateInput(st.registrationDeadline as string) : "",
authenticationType: apiAuthenticationTypeToForm(st.authenticationType), authenticationType: apiAuthenticationTypeToForm(st.authenticationType),
@ -242,6 +250,7 @@ export function mergeActivitySettingsFormWithStored(form: EventForm, storedEvent
endDate: t(form.endDate) || d.endDate, endDate: t(form.endDate) || d.endDate,
registrationRequired: form.registrationRequired, registrationRequired: form.registrationRequired,
registrationMode: form.registrationMode || d.registrationMode, registrationMode: form.registrationMode || d.registrationMode,
registrationApproval: form.registrationApproval || d.registrationApproval,
verifyEmail: form.verifyEmail, verifyEmail: form.verifyEmail,
registrationDeadline: form.registrationDeadline, registrationDeadline: form.registrationDeadline,
authenticationType: form.authenticationType || d.authenticationType, authenticationType: form.authenticationType || d.authenticationType,

View File

@ -18,11 +18,11 @@ export function ParticipantBulkActionBar({
selectedCount: number; selectedCount: number;
busy?: boolean; busy?: boolean;
variant: ParticipantBulkVariant; variant: ParticipantBulkVariant;
onAddTags: () => void; onAddTags?: () => void;
onRemoveTags: () => void; onRemoveTags?: () => void;
onAssignEvents?: () => void; onAssignEvents?: () => void;
onNewList: () => void; onNewList?: () => void;
onAddToList: () => void; onAddToList?: () => void;
onDeleteSelected?: () => void; onDeleteSelected?: () => void;
onRemoveFromEvent?: () => void; onRemoveFromEvent?: () => void;
onClearSelection: () => void; onClearSelection: () => void;
@ -55,13 +55,13 @@ export function ParticipantBulkActionBar({
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}> <span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
{selectedCount} selected {selectedCount} selected
</span> </span>
<button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button> {onAddTags ? <button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button> : null}
<button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button> {onRemoveTags ? <button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button> : null}
{variant === "portfolio" && onAssignEvents ? ( {variant === "portfolio" && onAssignEvents ? (
<button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button> <button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button>
) : null} ) : null}
<button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button> {onNewList ? <button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button> : null}
<button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button> {onAddToList ? <button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button> : null}
{variant === "portfolio" && onDeleteSelected ? ( {variant === "portfolio" && onDeleteSelected ? (
<button <button
type="button" type="button"

View File

@ -14,6 +14,7 @@ export function ParticipantBulkDialogs({
onToggleTag, onToggleTag,
availableTags, availableTags,
removeTagOptions, removeTagOptions,
tagScope = "global",
newListName, newListName,
onNewListNameChange, onNewListNameChange,
newListDesc, newListDesc,
@ -37,6 +38,8 @@ export function ParticipantBulkDialogs({
onToggleTag: (tag: string) => void; onToggleTag: (tag: string) => void;
availableTags: string[]; availableTags: string[];
removeTagOptions: string[]; removeTagOptions: string[];
/** Event page = tags stored on this event membership; global = org-wide root tags. */
tagScope?: "event" | "global";
newListName: string; newListName: string;
onNewListNameChange: (v: string) => void; onNewListNameChange: (v: string) => void;
newListDesc: string; newListDesc: string;
@ -99,12 +102,26 @@ export function ParticipantBulkDialogs({
if (bulkDialog === "addTags" || bulkDialog === "removeTags") { if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags; const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags;
const emptyMessage = bulkDialog === "removeTags" ? "No tags are currently assigned to the selected participants." : "No tags are available yet for this event."; const emptyMessage =
bulkDialog === "removeTags"
? "No tags are currently assigned to the selected participants."
: tagScope === "event"
? "No tags are available yet for this event."
: "No tags are available yet for this organisation.";
const scopeHint =
tagScope === "event"
? bulkDialog === "addTags"
? "Tags apply to this event only (also shown on the person’s global record, not on other events)."
: "Removes tags from this event only. Organisation-wide tags of the same name stay visible here."
: bulkDialog === "addTags"
? "Tags apply organisation-wide and appear on every event for these people."
: "Removes the tag from the person entirely (organisation-wide and any event-specific copies).";
return overlay( return overlay(
<> <>
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3> <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)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p> <p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{scopeHint}</p>
<input <input
value={manualTagInput} value={manualTagInput}
onChange={(e) => setManualTagInput(e.target.value)} onChange={(e) => setManualTagInput(e.target.value)}

View File

@ -2,11 +2,12 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../contexts/PermissionContext";
import { import {
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X, Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
Phone, Mail, Calendar, User, Filter, UserCheck, Phone, Mail, Calendar, User, Filter, UserCheck,
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock, AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
Users, UserX, ChevronLeft, ChevronRight, QrCode, Users, ChevronLeft, ChevronRight, QrCode,
} from "lucide-react"; } from "lucide-react";
import { participantsApi } from "../../../services/api"; import { participantsApi } from "../../../services/api";
import type { Participant as ApiParticipant } from "../../../services/api/types"; import type { Participant as ApiParticipant } from "../../../services/api/types";
@ -42,7 +43,7 @@ interface ParticipantDisplay {
function toDisplay(p: ApiParticipant): ParticipantDisplay { function toDisplay(p: ApiParticipant): ParticipantDisplay {
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—"; const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
const regDate = p.registrationDate ? new Date(p.registrationDate).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) : null; const regDate = p.registrationDate ? new Date(p.registrationDate).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) : null;
const statusLabel = { pending: "Pending", confirmed: "Confirmed", checked_in: "Checked In", cancelled: "Cancelled" }[p.status] ?? p.status; const statusLabel = { pending: "Pending", confirmed: "Confirmed", checked_in: "Checked In", cancelled: "Cancelled", rejected: "Rejected" }[p.status] ?? p.status;
return { return {
id: p._id, id: p._id,
name, name,
@ -142,8 +143,18 @@ function CheckInBadge({ checked }: { checked: boolean }) {
} }
function StatusDot({ status }: { status: string }) { function StatusDot({ status }: { status: string }) {
const color = status === "Cancelled" ? "var(--muted)" : status === "Checked In" ? "#15803d" : status === "Confirmed" ? "#2563eb" : "var(--card-foreground)"; const color =
const dotColor = status === "Cancelled" ? "#9ca3af" : status === "Checked In" ? "#16a34a" : status === "Confirmed" ? "#2563eb" : "#94a3b8"; status === "Rejected" ? "var(--destructive)"
: status === "Cancelled" ? "var(--muted)"
: status === "Checked In" ? "#15803d"
: status === "Confirmed" ? "#2563eb"
: "var(--card-foreground)";
const dotColor =
status === "Rejected" ? "#dc2626"
: status === "Cancelled" ? "#9ca3af"
: status === "Checked In" ? "#16a34a"
: status === "Confirmed" ? "#2563eb"
: "#94a3b8";
return ( return (
<span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontSize: "11px", fontFamily: "var(--font-noto-sans)", color }}> <span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontSize: "11px", fontFamily: "var(--font-noto-sans)", color }}>
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: dotColor, display: "block" }} /> <span style={{ width: "6px", height: "6px", borderRadius: "50%", background: dotColor, display: "block" }} />
@ -152,6 +163,20 @@ function StatusDot({ status }: { status: string }) {
); );
} }
const iconBtnStyle: React.CSSProperties = {
width: "28px",
height: "28px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
borderRadius: "var(--radius-button)",
background: "var(--card)",
border: "1px solid var(--border)",
color: "var(--card-foreground)",
cursor: "pointer",
};
/* ─────────────── Filter Chip ─────────────── */ /* ─────────────── Filter Chip ─────────────── */
function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) { function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
@ -184,16 +209,19 @@ interface RowProps {
onView: () => void; onView: () => void;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onToggleCheckIn: () => void; onConfirm: () => void;
onReject: () => void;
onQr: () => void; onQr: () => void;
canEdit?: boolean; canEdit?: boolean;
canDelete?: boolean;
selectable?: boolean; selectable?: boolean;
selected?: boolean; selected?: boolean;
onToggleSelect?: () => void; onToggleSelect?: () => void;
} }
function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) { function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isPending = p.status === "Pending";
return ( return (
<tr <tr
@ -260,39 +288,46 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
{/* Actions — always visible */} {/* Actions — always visible */}
<td style={{ padding: "12px 16px 12px 8px", whiteSpace: "nowrap" }}> <td style={{ padding: "12px 16px 12px 8px", whiteSpace: "nowrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}> <div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{/* Quick check-in */} {isPending && (
<button <>
onClick={onToggleCheckIn} <button
title={p.checkedIn ? "Undo check-in" : "Mark checked in"} type="button"
style={{ onClick={onConfirm}
display: "flex", alignItems: "center", gap: "4px", title="Confirm registration"
padding: "4px 10px", borderRadius: "var(--radius-button)", aria-label="Confirm registration"
background: p.checkedIn ? "var(--secondary)" : "#dcfce7", style={{
border: `1px solid ${p.checkedIn ? "var(--border)" : "#86efac"}`, display: "flex", alignItems: "center", gap: "4px",
color: p.checkedIn ? "var(--muted)" : "#15803d", padding: "4px 10px", borderRadius: "var(--radius-button)",
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", background: "#dcfce7", border: "1px solid #86efac", color: "#15803d",
cursor: "pointer", fontWeight: "var(--font-weight-medium)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
}} cursor: "pointer", fontWeight: "var(--font-weight-medium)",
> }}
{p.checkedIn ? <X size={11} /> : <Check size={11} />} >
{p.checkedIn ? "Undo" : "Check In"} <Check size={11} /> Confirm
</button> </button>
<button
type="button"
onClick={onReject}
title="Reject registration"
aria-label="Reject registration"
style={{
display: "flex", alignItems: "center", gap: "4px",
padding: "4px 10px", borderRadius: "var(--radius-button)",
background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c",
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
}}
>
<X size={11} /> Reject
</button>
</>
)}
<button <button
type="button"
onClick={onQr} onClick={onQr}
title="Check-in QR code (EventBuzz Checkins app)" title="Check-in QR code (EventBuzz Checkins app)"
style={{ aria-label="Check-in QR code"
display: "flex", style={iconBtnStyle}
alignItems: "center",
gap: "4px",
padding: "4px 10px",
borderRadius: "var(--radius-button)",
background: "var(--card)",
border: "1px solid var(--border)",
color: "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
cursor: "pointer",
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)";
@ -302,32 +337,45 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}} }}
> >
<QrCode size={11} /> QR <QrCode size={13} />
</button> </button>
{/* View */} <button
<button onClick={onView} title="View profile" type="button"
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }} onClick={onView}
title="View profile"
aria-label="View profile"
style={iconBtnStyle}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} 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)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
> >
<Eye size={11} /> View <Eye size={13} />
</button> </button>
{canEdit && ( {canEdit && (
<button onClick={onEdit} title="Edit participant" <button
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }} type="button"
onClick={onEdit}
title="Edit participant"
aria-label="Edit participant"
style={iconBtnStyle}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} 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)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
> >
<Pencil size={11} /> Edit <Pencil size={13} />
</button>
)}
{canDelete && (
<button
type="button"
onClick={onDelete}
title="Delete participant"
aria-label="Delete participant"
style={{ ...iconBtnStyle, color: "var(--destructive)" }}
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)"; }}
>
<Trash2 size={13} />
</button> </button>
)} )}
<button onClick={onDelete} title="Delete participant"
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--destructive)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
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)"; }}
>
<Trash2 size={11} /> Delete
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -336,8 +384,9 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
/* ─────────────── Participant Card ─────────────── */ /* ─────────────── Participant Card ─────────────── */
function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) { function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isPending = p.status === "Pending";
return ( return (
<div <div
@ -371,22 +420,6 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
<StatusDot status={p.status} /> <StatusDot status={p.status} />
</div> </div>
</div> </div>
{/* Check-in quick toggle — always visible */}
<button
onClick={onToggleCheckIn}
title={p.checkedIn ? "Undo check-in" : "Mark as checked in"}
style={{
width: "28px", height: "28px", flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
borderRadius: "50%",
background: p.checkedIn ? "#dcfce7" : "var(--secondary)",
border: `1px solid ${p.checkedIn ? "#86efac" : "var(--border)"}`,
color: p.checkedIn ? "#15803d" : "var(--muted)",
cursor: "pointer",
}}
>
<Check size={13} />
</button>
</div> </div>
{/* Contact details */} {/* Contact details */}
@ -407,13 +440,48 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
)} )}
</div> </div>
{isPending && (
<div style={{ display: "flex", gap: "6px" }}>
<button
type="button"
onClick={onConfirm}
title="Confirm registration"
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "4px",
padding: "6px 8px", borderRadius: "var(--radius-button)",
background: "#dcfce7", border: "1px solid #86efac", color: "#15803d",
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
}}
>
<Check size={11} /> Confirm
</button>
<button
type="button"
onClick={onReject}
title="Reject registration"
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "4px",
padding: "6px 8px", borderRadius: "var(--radius-button)",
background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c",
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
}}
>
<X size={11} /> Reject
</button>
</div>
)}
{/* Footer: check-in badge + actions */} {/* Footer: check-in badge + actions */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "10px", borderTop: "1px solid var(--border)" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "10px", borderTop: "1px solid var(--border)" }}>
<CheckInBadge checked={p.checkedIn} /> <CheckInBadge checked={p.checkedIn} />
<div style={{ display: "flex", gap: "4px" }}> <div style={{ display: "flex", gap: "4px" }}>
<button <button
type="button"
onClick={onQr} onClick={onQr}
title="Check-in QR code" title="Check-in QR code"
aria-label="Check-in QR code"
style={{ style={{
width: "26px", width: "26px",
height: "26px", height: "26px",
@ -435,26 +503,28 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
> >
<QrCode size={12} /> <QrCode size={12} />
</button> </button>
<button onClick={onView} title="View" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }} <button type="button" onClick={onView} title="View" aria-label="View profile" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
> >
<Eye size={12} /> <Eye size={12} />
</button> </button>
{canEdit && ( {canEdit && (
<button onClick={onEdit} title="Edit" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }} <button type="button" onClick={onEdit} title="Edit" aria-label="Edit participant" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
> >
<Pencil size={12} /> <Pencil size={12} />
</button> </button>
)} )}
<button onClick={onDelete} title="Delete" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }} {canDelete && (
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }} <button type="button" onClick={onDelete} title="Delete" aria-label="Delete participant" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
> onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
<Trash2 size={12} /> >
</button> <Trash2 size={12} />
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -515,8 +585,8 @@ function ViewModal({ p, onClose, onEdit, canEdit }: { p: ParticipantDisplay; onC
); );
} }
const STATUS_OPTIONS = ["Pending", "Confirmed", "Checked In", "Cancelled"]; const STATUS_OPTIONS = ["Pending", "Confirmed", "Checked In", "Cancelled", "Rejected"];
const STATUS_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "cancelled" }; const STATUS_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "cancelled", "Rejected": "rejected" };
const DESIGNATION_OPTIONS = ["Delegate", "Speaker", "Moderator", "Panelist"]; const DESIGNATION_OPTIONS = ["Delegate", "Speaker", "Moderator", "Panelist"];
/* ─────────────── Confirm Modal ─────────────── */ /* ─────────────── Confirm Modal ─────────────── */
@ -553,10 +623,14 @@ export type ParticipantsPageProps = {
export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) { export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) {
const { selectedEventId, setActiveNav } = useAppNav(); const { selectedEventId, setActiveNav } = useAppNav();
const { user } = useAuth(); const { user } = useAuth();
const { canModule } = usePermission();
const canWriteParticipants = canModule("participants", "write");
const canDeleteParticipants = canModule("participants", "delete");
const listOrgId = portfolioListOrganizationId(user); const listOrgId = portfolioListOrganizationId(user);
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
const isSuperAdmin = user?.role === "super_admin"; const isSuperAdmin = user?.role === "super_admin";
const canEditParticipants = canWriteParticipants || isSuperAdmin;
const enableBulk = !compactChrome; const enableBulk = !compactChrome;
const [list, setList] = useState<ParticipantDisplay[]>([]); const [list, setList] = useState<ParticipantDisplay[]>([]);
@ -678,7 +752,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
const totalPages = Math.max(1, Math.ceil(total / limit)); const totalPages = Math.max(1, Math.ceil(total / limit));
const checkedIn = list.filter(p => p.checkedIn).length; const checkedIn = list.filter(p => p.checkedIn).length;
const speakers = list.filter(p => ["Speaker", "Moderator", "Panelist"].includes(p.role)).length;
const deleteParticipant = (id: string) => { const deleteParticipant = (id: string) => {
const p = list.find(x => x.id === id); const p = list.find(x => x.id === id);
@ -691,9 +764,20 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
}); });
}; };
const toggleCheckIn = (id: string) => { const confirmRegistration = (id: string) => {
if (!selectedEventId) return; if (!selectedEventId) return;
participantsApi.checkIn(id, selectedEventId).then(() => fetchList()); participantsApi.confirm(id, selectedEventId).then((res) => {
if (res.error) setPageMsg(res.error);
else fetchList();
});
};
const rejectRegistration = (id: string) => {
if (!selectedEventId) return;
participantsApi.reject(id, selectedEventId).then((res) => {
if (res.error) setPageMsg(res.error);
else fetchList();
});
}; };
const exportAllCsv = async () => { const exportAllCsv = async () => {
@ -810,27 +894,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
</div> </div>
</div> </div>
{/* ── Stats strip ── */}
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(4, 1fr)", gap: isMobile ? "10px" : "12px" }}>
{[
{ icon: <Users size={14} />, label: "Total Registered", value: total, accent: "var(--chart-1)" },
{ icon: <UserCheck size={14} />, label: "Checked In (this page)", value: checkedIn, accent: "var(--chart-2)", sub: list.length ? `${Math.round((checkedIn / list.length) * 100)}%` : "" },
{ icon: <UserX size={14} />, label: "Not Checked In (this page)", value: list.length - checkedIn, accent: "var(--chart-3)" },
{ icon: <User size={14} />, label: "Speakers & Staff (this page)", value: speakers, accent: "var(--chart-4)" },
].map(stat => (
<div key={stat.label} style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "14px 16px", display: "flex", alignItems: "center", gap: "12px", borderLeft: `3px solid ${stat.accent}` }}>
<div style={{ width: "32px", height: "32px", borderRadius: "var(--radius-button)", background: stat.accent + "15", display: "flex", alignItems: "center", justifyContent: "center", color: stat.accent, flexShrink: 0 }}>
{stat.icon}
</div>
<div>
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: "1.1" }}>{stat.value}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "2px" }}>{stat.label}</p>
{stat.sub && <p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "#16a34a", marginTop: "3px" }}>{stat.sub}</p>}
</div>
</div>
))}
</div>
{/* ── Filter + search bar ── */} {/* ── Filter + search bar ── */}
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{/* Top row: search + filter toggle + view toggle */} {/* Top row: search + filter toggle + view toggle */}
@ -906,9 +969,13 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onRemoveTags={() => void bulk.openBulkDialog("removeTags")} onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
onNewList={() => void bulk.openBulkDialog("newList")} onNewList={() => void bulk.openBulkDialog("newList")}
onAddToList={() => void bulk.openBulkDialog("addToList")} onAddToList={() => void bulk.openBulkDialog("addToList")}
onRemoveFromEvent={() => { onRemoveFromEvent={
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent(); canDeleteParticipants
}} ? () => {
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
}
: undefined
}
onClearSelection={clearSelection} onClearSelection={clearSelection}
/> />
) : null} ) : null}
@ -963,9 +1030,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onView={() => setViewModal(p)} onView={() => setViewModal(p)}
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }} onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
onDelete={() => deleteParticipant(p.id)} onDelete={() => deleteParticipant(p.id)}
onToggleCheckIn={() => toggleCheckIn(p.id)} onConfirm={() => confirmRegistration(p.id)}
onReject={() => rejectRegistration(p.id)}
onQr={() => setQrParticipant(p)} onQr={() => setQrParticipant(p)}
canEdit={isSuperAdmin} canEdit={canEditParticipants}
canDelete={canDeleteParticipants}
/> />
))} ))}
</tbody> </tbody>
@ -1001,9 +1070,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onView={() => setViewModal(p)} onView={() => setViewModal(p)}
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }} onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
onDelete={() => deleteParticipant(p.id)} onDelete={() => deleteParticipant(p.id)}
onToggleCheckIn={() => toggleCheckIn(p.id)} onConfirm={() => confirmRegistration(p.id)}
onReject={() => rejectRegistration(p.id)}
onQr={() => setQrParticipant(p)} onQr={() => setQrParticipant(p)}
canEdit={isSuperAdmin} canEdit={canEditParticipants}
canDelete={canDeleteParticipants}
/> />
))} ))}
</div> </div>
@ -1041,7 +1112,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
p={viewModal} p={viewModal}
onClose={() => setViewModal(null)} onClose={() => setViewModal(null)}
onEdit={() => { setEditId(viewModal.id); setViewModal(null); setShowAddPanel(true); }} onEdit={() => { setEditId(viewModal.id); setViewModal(null); setShowAddPanel(true); }}
canEdit={isSuperAdmin} canEdit={canEditParticipants}
/> />
)} )}
{qrParticipant && ( {qrParticipant && (
@ -1097,6 +1168,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onToggleTag={bulk.toggleSelectedTag} onToggleTag={bulk.toggleSelectedTag}
availableTags={tagOptions} availableTags={tagOptions}
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))} removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
tagScope={selectedEventId ? "event" : "global"}
newListName={bulk.newListName} newListName={bulk.newListName}
onNewListNameChange={bulk.setNewListName} onNewListNameChange={bulk.setNewListName}
newListDesc={bulk.newListDesc} newListDesc={bulk.newListDesc}

View File

@ -70,7 +70,12 @@ export function useParticipantBulkActions(options: {
const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean); const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean);
if (!tags.length) return; if (!tags.length) return;
setBulkBusy(true); setBulkBusy(true);
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags }); const res = await participantsApi.bulk({
action,
participantIds: selectedIds,
tags,
...(eventId ? { eventId } : {}),
});
setBulkBusy(false); setBulkBusy(false);
if (res.error) onMessage(res.error); if (res.error) onMessage(res.error);
else { else {

View File

@ -1,14 +1,18 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo, useEffect } from "react";
import { import {
Search, X, ArrowRight, MapPin, Calendar, Users, Search, X, ArrowRight, MapPin, Calendar, Users,
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { import {
doSearch, ALL_EVENTS, ALL_PAGES, ALL_ACTIONS, doSearch, ALL_PAGES, ALL_ACTIONS,
STATUS_BADGE, LOC_ICON, STATUS_BADGE, LOC_ICON,
mapApiEventToSearchEvent,
SearchEvent, SearchPage, SearchAction, SearchEvent, SearchPage, SearchAction,
} from "./searchIndex"; } from "./searchIndex";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext";
import { eventsApi } from "../../../services/api";
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
/* ───────────────────────────────────────── /* ─────────────────────────────────────────
Props Props
@ -230,8 +234,36 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
const [activeTab, setActiveTab] = useState<FilterTab>("all"); const [activeTab, setActiveTab] = useState<FilterTab>("all");
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
const results = useMemo(() => doSearch(query), [query]); useEffect(() => {
if (!listOrgId) {
setOrgSearchEvents([]);
return;
}
let cancelled = false;
void (async () => {
const res = await eventsApi.list({
organization: listOrgId,
recordKind: "event",
page: 1,
limit: 200,
});
if (cancelled) return;
if (res.error || !res.data?.events) {
setOrgSearchEvents([]);
return;
}
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
})();
return () => {
cancelled = true;
};
}, [listOrgId]);
const results = useMemo(() => doSearch(query, orgSearchEvents), [query, orgSearchEvents]);
const trimmed = query.trim(); const trimmed = query.trim();
/* Filter tab data */ /* Filter tab data */
@ -312,9 +344,14 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
Recent Events Recent Events
</p> </p>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{ALL_EVENTS.slice(0, 5).map(e => ( {orgSearchEvents.slice(0, 5).map(e => (
<EventCard key={e.id} event={e} query="" onClick={() => handleEventClick(e)} isActive={false} /> <EventCard key={e.id} event={e} query="" onClick={() => handleEventClick(e)} isActive={false} />
))} ))}
{orgSearchEvents.length === 0 && (
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
No events in this organisation yet.
</p>
)}
</div> </div>
</div> </div>

View File

@ -44,21 +44,58 @@ export const LOC_ICON: Record<LocationType, React.ReactNode> = {
Hybrid: <Zap size={10} />, Hybrid: <Zap size={10} />,
}; };
/* ─── Events ─── */ /* ─── Events (populated at runtime from current-org API; no cross-org demo data) ─── */
export const ALL_EVENTS: SearchEvent[] = [ export const ALL_EVENTS: SearchEvent[] = [];
{ id: "e1", title: "Napcon Jaipur 2026", subtitle: "27th National Conference on Pulmonary Diseases", status: "Live", type: "Conference", location: "Birla Auditorium, Jaipur", locationType: "Physical", startDate: "Nov 23, 2025", endDate: "Nov 26, 2025", participants: 26, sessions: 12 },
{ id: "e2", title: "Napcon Mumbai 2025", subtitle: "26th National Conference on Pulmonary Diseases", status: "Completed", type: "Conference", location: "NSCI Dome, Mumbai", locationType: "Physical", startDate: "Oct 12, 2025", endDate: "Oct 15, 2025", participants: 342, sessions: 28 }, function formatShortDate(iso: string | undefined): string {
{ id: "e3", title: "CHEST India 2026", subtitle: "Annual Indian CHEST Conference", status: "Upcoming", type: "Conference", location: "Taj Palace, New Delhi", locationType: "Physical", startDate: "Mar 15, 2026", endDate: "Mar 18, 2026", participants: 0, sessions: 0 }, if (!iso) return "";
{ id: "e4", title: "ICS Annual Conference 2026", subtitle: "Indian Chest Society Annual Scientific Conference", status: "Upcoming", type: "Conference", location: "Hotel Lalit, Bengaluru", locationType: "Hybrid", startDate: "Jun 10, 2026", endDate: "Jun 12, 2026", participants: 0, sessions: 0 }, const d = new Date(iso);
{ id: "e5", title: "COPD Management Workshop", subtitle: "Evidence-based COPD Treatment Protocols", status: "Completed", type: "Workshop", location: "AIIMS, New Delhi", locationType: "Physical", startDate: "Sep 05, 2025", endDate: "Sep 05, 2025", participants: 48, sessions: 6 }, if (Number.isNaN(d.getTime())) return "";
{ id: "e6", title: "Interventional Pulmonology", subtitle: "Advanced Bronchoscopy & Intervention Training", status: "Completed", type: "Workshop", location: "PGI Chandigarh", locationType: "Physical", startDate: "Nov 28, 2025", endDate: "Nov 29, 2025", participants: 22, sessions: 8 }, return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
{ id: "e7", title: "Respiratory Physiology Lab", subtitle: "Masterclass: PFT & Interpretation", status: "Upcoming", type: "Workshop", location: "Online", locationType: "Virtual", startDate: "Apr 10, 2026", endDate: "Apr 10, 2026", participants: 0, sessions: 0 }, }
{ id: "e8", title: "IMA Annual Summit 2025", subtitle: "Indian Medical Association National Congress", status: "Completed", type: "Summit", location: "Vigyan Bhavan, New Delhi", locationType: "Physical", startDate: "Aug 20, 2025", endDate: "Aug 22, 2025", participants: 520, sessions: 18 },
{ id: "e9", title: "AIIMS Research Conclave 2026",subtitle: "National Medical Research & Innovation Summit", status: "Upcoming", type: "Summit", location: "AIIMS Auditorium, New Delhi", locationType: "Physical", startDate: "Jul 15, 2026", endDate: "Jul 17, 2026", participants: 0, sessions: 0 }, function toEventStatus(raw: string | undefined): EventStatus {
{ id: "e10", title: "Monthly CME – December 2025", subtitle: "Asthma & Allergy Management Update", status: "Upcoming", type: "CME", location: "Online", locationType: "Virtual", startDate: "Dec 20, 2025", endDate: "Dec 20, 2025", participants: 0, sessions: 0 }, const k = String(raw || "draft").toLowerCase();
{ id: "e11", title: "Monthly CME – November 2025", subtitle: "COPD Exacerbations: Case Studies", status: "Completed", type: "CME", location: "Online", locationType: "Virtual", startDate: "Nov 20, 2025", endDate: "Nov 20, 2025", participants: 65, sessions: 4 }, if (k === "live") return "Live";
{ id: "e12", title: "Monthly CME – October 2025", subtitle: "Sleep Apnoea: Diagnosis & Management", status: "Completed", type: "CME", location: "Online", locationType: "Virtual", startDate: "Oct 18, 2025", endDate: "Oct 18, 2025", participants: 72, sessions: 4 }, if (k === "upcoming") return "Upcoming";
]; if (k === "completed") return "Completed";
return "Draft";
}
function toLocationType(raw: string | undefined): LocationType {
const k = String(raw || "physical").toLowerCase();
if (k === "virtual") return "Virtual";
if (k === "hybrid") return "Hybrid";
return "Physical";
}
/** Map a dashboard Event API record into the search index shape. */
export function mapApiEventToSearchEvent(ev: {
_id: string;
name?: string;
subtitle?: string;
description?: string;
status?: string;
type?: string;
location?: string;
locationType?: string;
startDate?: string;
endDate?: string;
}): SearchEvent {
return {
id: String(ev._id),
title: String(ev.name || "Untitled event"),
subtitle: String(ev.subtitle || ev.description || "").slice(0, 160),
status: toEventStatus(ev.status),
type: String(ev.type || "Conference"),
location: String(ev.location || (ev.locationType === "virtual" ? "Online" : "")),
locationType: toLocationType(ev.locationType),
startDate: formatShortDate(ev.startDate),
endDate: formatShortDate(ev.endDate),
participants: 0,
sessions: 0,
};
}
/* ─── Pages ─── */ /* ─── Pages ─── */
export const ALL_PAGES: SearchPage[] = [ export const ALL_PAGES: SearchPage[] = [
@ -113,13 +150,13 @@ export const SUGGESTED_SHORTCUTS: { label: string; navId: string; icon: React.Re
]; ];
/* ─── Search function ─── */ /* ─── Search function ─── */
export function doSearch(query: string): SearchResults { export function doSearch(query: string, orgEvents: SearchEvent[] = ALL_EVENTS): SearchResults {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return { events: [], pages: [], actions: [], total: 0 }; if (!q) return { events: [], pages: [], actions: [], total: 0 };
const match = (...strs: string[]) => strs.some(s => s.toLowerCase().includes(q)); const match = (...strs: string[]) => strs.some(s => s.toLowerCase().includes(q));
const events = ALL_EVENTS.filter(e => const events = orgEvents.filter(e =>
match(e.title, e.subtitle, e.location, e.type, e.status)); match(e.title, e.subtitle, e.location, e.type, e.status));
const pages = ALL_PAGES.filter(p => const pages = ALL_PAGES.filter(p =>
match(p.title, p.section, p.description)); match(p.title, p.section, p.description));

View File

@ -30,6 +30,106 @@ function parseDatetimeLocal(s: string): Date | undefined {
return Number.isNaN(d.getTime()) ? undefined : d; return Number.isNaN(d.getTime()) ? undefined : d;
} }
function parseDateOnly(s: string): Date | undefined {
const raw = s.trim();
if (!raw) return undefined;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const [y, m, d] = raw.split("-").map(Number);
const date = new Date(y, m - 1, d);
return Number.isNaN(date.getTime()) ? undefined : date;
}
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? undefined : d;
}
/** Local calendar date as `YYYY-MM-DD`. */
export function toDateOnlyValue(isoOrDate: string | Date | undefined): string {
if (!isoOrDate) return "";
const d = typeof isoOrDate === "string" ? parseDateOnly(isoOrDate) : isoOrDate;
if (!d || Number.isNaN(d.getTime())) return "";
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
export interface FlatpickrDateInputProps {
value: string;
onChange: (value: string) => void;
style?: React.CSSProperties;
disabled?: boolean;
placeholder?: string;
hasError?: boolean;
}
/** Date-only picker (Flatpickr) that reads/writes `YYYY-MM-DD`. */
export function FlatpickrDateInput({
value,
onChange,
style,
disabled,
placeholder = "Select date…",
hasError,
}: FlatpickrDateInputProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const fpRef = React.useRef<FlatpickrHandle | null>(null);
React.useEffect(() => {
const el = inputRef.current;
if (!el) return;
const fp = flatpickr(el, {
enableTime: false,
dateFormat: "Y-m-d",
allowInput: true,
defaultDate: parseDateOnly(value),
onChange: dates => {
const d = dates[0];
onChange(d ? toDateOnlyValue(d) : "");
},
}) as FlatpickrHandle;
fpRef.current = fp;
return () => {
fp.destroy();
fpRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- init once
}, []);
React.useEffect(() => {
const fp = fpRef.current;
if (!fp) return;
const selected = fp.selectedDates[0];
const cur = selected ? toDateOnlyValue(selected) : "";
if (value === cur) return;
const d = parseDateOnly(value);
if (d) fp.setDate(d, false);
else fp.clear(false);
}, [value]);
React.useEffect(() => {
const fp = fpRef.current;
if (!fp) return;
const input = fp.input as HTMLInputElement;
input.disabled = !!disabled;
fp.set("clickOpens", !disabled);
}, [disabled]);
return (
<input
ref={inputRef}
type="text"
placeholder={placeholder}
readOnly={false}
autoComplete="off"
style={{
...style,
...(hasError ? { borderColor: "var(--destructive)" } : {}),
}}
/>
);
}
export interface FlatpickrDatetimeInputProps { export interface FlatpickrDatetimeInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;

View File

@ -12,11 +12,16 @@ interface PermissionContextValue {
loading: boolean; loading: boolean;
} }
const ACTION_IMPLIES: Record<AclAction, AclAction[]> = { /** Higher privileges grant lower ones: delete ⇒ write ⇒ read (matches backend actionsInclude). */
read: ["read"], function actionsInclude(entryActions: string[], action: AclAction): boolean {
write: ["read", "write"], if (!Array.isArray(entryActions)) return false;
delete: ["read", "write", "delete"], if (entryActions.includes(action)) return true;
}; if (action === "read" && (entryActions.includes("write") || entryActions.includes("delete"))) {
return true;
}
if (action === "write" && entryActions.includes("delete")) return true;
return false;
}
const PermissionContext = createContext<PermissionContextValue | null>(null); const PermissionContext = createContext<PermissionContextValue | null>(null);
@ -29,7 +34,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
if (!permissions) return user?.role === "super_admin"; if (!permissions) return user?.role === "super_admin";
if (permissions.isOrgSuperAdmin) return true; if (permissions.isOrgSuperAdmin) return true;
const actions = permissions.modules?.[moduleKey] || []; const actions = permissions.modules?.[moduleKey] || [];
return (ACTION_IMPLIES[action] || [action]).some((a) => actions.includes(a)); return actionsInclude(actions, action);
}, },
[permissions, user?.role], [permissions, user?.role],
); );
@ -72,6 +77,8 @@ export const NAV_MODULE_MAP: Record<string, string> = {
activities: "live_polls", activities: "live_polls",
faculty: "speaker_management", faculty: "speaker_management",
"all-participants": "participants", "all-participants": "participants",
"participant-lists": "participants",
attendance: "attendance",
forms: "forms", forms: "forms",
"forms-feedback": "forms", "forms-feedback": "forms",
"forms-registration": "forms", "forms-registration": "forms",
@ -85,7 +92,6 @@ export const NAV_MODULE_MAP: Record<string, string> = {
payment: "billing", payment: "billing",
"user-management": "user_management", "user-management": "user_management",
"roles-management": "user_management", "roles-management": "user_management",
"participant-lists": "participants",
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging", "messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
settings: "settings", settings: "settings",
design: "custom_badge_and_print_file_creation", design: "custom_badge_and_print_file_creation",

View File

@ -0,0 +1,109 @@
/**
* Client-side emailer preview builder (mirrors backend static preview).
*/
const FONT_STACK: Record<string, string> = {
"Inter + Noto Sans": "system-ui, -apple-system, 'Segoe UI', sans-serif",
"Merriweather + Open Sans": "Georgia, 'Merriweather', serif",
"Poppins + Lato": "'Poppins', 'Lato', system-ui, sans-serif",
"Georgia + Source Sans": "Georgia, 'Source Sans Pro', sans-serif",
};
const SAMPLE_VARS: Record<string, string> = {
"contact.firstName": "Jane",
"contact.lastName": "Doe",
"contact.name": "Jane Doe",
"contact.email": "jane@example.com",
"contact.organization": "Acme Inc.",
"contact.phone": "+91 98765 43210",
"event.name": "Annual Summit 2026",
"event.startDate": "15 Mar 2026, 9:00 am",
"event.endDate": "17 Mar 2026, 6:00 pm",
"event.venue": "Grand Hall, Mumbai",
"event.virtualLink": "https://meet.example.com/room/abc",
};
function escapeHtml(s: string) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function mergeText(text: string) {
let out = String(text || "");
for (const [k, v] of Object.entries(SAMPLE_VARS)) {
const re = new RegExp(`\\{\\{\\s*${k.replace(/\./g, "\\.")}\\s*\\}\\}`, "gi");
out = out.replace(re, v);
}
return out;
}
type PreviewBlock =
| { type: "text"; html?: string }
| { type: "image"; src?: string }
| { type: "cta"; label?: string; linkKind?: string; formType?: string; customUrl?: string }
| { type: "divider" };
function ctaPreviewUrl(blk: Extract<PreviewBlock, { type: "cta" }>) {
if (blk.linkKind === "form") {
if (blk.formType === "feedback") return "https://web.eventbuzz.live/event/sample/form/feedback";
return "https://web.eventbuzz.live/event/sample/form/register";
}
if (blk.linkKind === "microsite") return "https://web.eventbuzz.live/sample-event/";
return blk.customUrl?.trim() || "#";
}
export function buildEmailerPreviewHtml(content: Record<string, unknown> | undefined | null): string {
if (!content || typeof content !== "object") return "";
const stored = typeof content.html === "string" ? content.html.trim() : "";
const blocks = Array.isArray(content.blocks) ? (content.blocks as PreviewBlock[]) : [];
if (!blocks.length) return stored;
const accent = String(content.primaryColor || "#0B4DD1").trim();
const font = FONT_STACK[String(content.fontPair || "")] || FONT_STACK["Inter + Noto Sans"];
const headerRaw = String(content.headerImageUrl || "").trim();
const headerImg = /\{\{\s*event\.logoUrl\s*\}\}/i.test(headerRaw) ? "" : mergeText(headerRaw);
const footerText = mergeText(String(content.footerText || "© EventBuzz"));
const previewText = mergeText(String(content.previewText || "").trim());
const blocksHtml = blocks
.map((blk) => {
if (blk.type === "text") {
const inner = blk.html?.trim() ? mergeText(blk.html) : "";
return `<div style="margin:12px 0;font-size:15px;line-height:1.55;color:#334155;">${inner}</div>`;
}
if (blk.type === "image" && blk.src?.trim()) {
return `<p style="text-align:center;margin:16px 0;"><img src="${escapeHtml(mergeText(blk.src.trim()))}" alt="" style="max-width:100%;border-radius:8px;" /></p>`;
}
if (blk.type === "cta") {
const url = escapeHtml(ctaPreviewUrl(blk));
const label = escapeHtml(mergeText(blk.label || "Register"));
return `<p style="text-align:center;margin:20px 0;"><a href="${url}" style="display:inline-block;padding:12px 24px;background:${escapeHtml(accent)};color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">${label}</a></p>`;
}
if (blk.type === "divider") return `<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;" />`;
return "";
})
.join("\n");
const headerRow = headerImg
? `<tr><td style="padding:20px 28px 8px;text-align:center;"><img src="${escapeHtml(headerImg)}" alt="" style="max-height:72px;width:auto;display:inline-block;" /></td></tr>`
: "";
return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f8fafc;font-family:${font};">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
${headerRow}
<tr><td style="padding:24px 28px 8px;">
${previewText ? `<p style="margin:0 0 8px;font-size:12px;color:#64748b;">${escapeHtml(previewText)}</p>` : ""}
</td></tr>
<tr><td style="padding:0 28px 24px;">${blocksHtml}</td></tr>
<tr><td style="padding:16px 28px 24px;border-top:1px solid #f1f5f9;">
<p style="margin:0;font-size:12px;color:#94a3b8;">${escapeHtml(footerText)}</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
}

View File

@ -23,6 +23,12 @@ function formatDate(iso?: string) {
} }
} }
function formatAdminName(admin: OrganizationRecord["primaryAdmin"]) {
if (!admin) return "";
const name = [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim();
return name || "";
}
export function SuperAdminClientsPage() { export function SuperAdminClientsPage() {
const [rows, setRows] = useState<OrganizationRecord[]>([]); const [rows, setRows] = useState<OrganizationRecord[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@ -220,7 +226,7 @@ export function SuperAdminClientsPage() {
<input <input
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search by name or email" placeholder="Search by org name, email, or admin user"
style={{ ...inputStyle, flex: "1 1 220px", maxWidth: "360px" }} style={{ ...inputStyle, flex: "1 1 220px", maxWidth: "360px" }}
/> />
<button <button
@ -252,7 +258,8 @@ export function SuperAdminClientsPage() {
<thead> <thead>
<tr style={{ background: "var(--accent)", textAlign: "left" }}> <tr style={{ background: "var(--accent)", textAlign: "left" }}>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Name</th> <th style={{ padding: "10px 12px", fontWeight: 600 }}>Name</th>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Email</th> <th style={{ padding: "10px 12px", fontWeight: 600 }}>Org email</th>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Associated user</th>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Plan</th> <th style={{ padding: "10px 12px", fontWeight: 600 }}>Plan</th>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Status</th> <th style={{ padding: "10px 12px", fontWeight: 600 }}>Status</th>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Users</th> <th style={{ padding: "10px 12px", fontWeight: 600 }}>Users</th>
@ -263,40 +270,63 @@ export function SuperAdminClientsPage() {
<tbody> <tbody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td colSpan={7} style={{ padding: "24px 16px", color: "var(--muted)", textAlign: "center" }}> <td colSpan={8} style={{ padding: "24px 16px", color: "var(--muted)", textAlign: "center" }}>
{search {search
? "No organizations match your search." ? "No organizations match your search."
: "No organizations yet. Use “Add organization” to create a tenant and its first super admin user."} : "No organizations yet. Use “Add organization” to create a tenant and its first super admin user."}
</td> </td>
</tr> </tr>
) : ( ) : (
rows.map((org) => ( rows.map((org) => {
<tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}> const admin = org.primaryAdmin;
<td style={{ padding: "10px 12px", color: "var(--foreground)" }}>{org.name}</td> const adminName = formatAdminName(admin);
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</td> return (
<td style={{ padding: "10px 12px" }}>{org.subscription?.plan ?? "free"}</td> <tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}>
<td style={{ padding: "10px 12px" }}>{org.subscription?.status ?? "—"}</td> <td style={{ padding: "10px 12px", color: "var(--foreground)" }}>{org.name}</td>
<td style={{ padding: "10px 12px" }}>{org.userCount ?? "—"}</td> <td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</td>
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{formatDate(org.createdAt)}</td> <td style={{ padding: "10px 12px", verticalAlign: "top" }}>
<td style={{ padding: "10px 12px" }}> {admin ? (
<button <div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
type="button" <span style={{ color: "var(--foreground)", fontWeight: 500 }}>
onClick={() => openEdit(org)} {adminName || "—"}
style={{ </span>
background: "transparent", <span style={{ color: "var(--muted)", fontSize: "var(--text-xs)" }}>
border: "1px solid var(--border)", {admin.email || "—"}
borderRadius: "var(--radius-button)", </span>
padding: "4px 10px", {admin.phone ? (
fontSize: "var(--text-xs)", <span style={{ color: "var(--muted)", fontSize: "var(--text-xs)" }}>
cursor: "pointer", {admin.phone}
color: "var(--primary)", </span>
}} ) : null}
> </div>
Edit sub ) : (
</button> <span style={{ color: "var(--muted)" }}>—</span>
</td> )}
</tr> </td>
)) <td style={{ padding: "10px 12px" }}>{org.subscription?.plan ?? "free"}</td>
<td style={{ padding: "10px 12px" }}>{org.subscription?.status ?? "—"}</td>
<td style={{ padding: "10px 12px" }}>{org.userCount ?? "—"}</td>
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{formatDate(org.createdAt)}</td>
<td style={{ padding: "10px 12px" }}>
<button
type="button"
onClick={() => openEdit(org)}
style={{
background: "transparent",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
padding: "4px 10px",
fontSize: "var(--text-xs)",
cursor: "pointer",
color: "var(--primary)",
}}
>
Edit sub
</button>
</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@ -18,6 +18,7 @@ export const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
pending: { bg: '#fef3c7', color: '#b45309' }, pending: { bg: '#fef3c7', color: '#b45309' },
confirmed: { bg: '#dcfce7', color: '#16a34a' }, confirmed: { bg: '#dcfce7', color: '#16a34a' },
checked_in: { bg: '#dbeafe', color: '#2563eb' }, checked_in: { bg: '#dbeafe', color: '#2563eb' },
rejected: { bg: '#fee2e2', color: '#dc2626' },
}; };
export const ROLE_STYLES: Record<string, { bg: string; color: string }> = { export const ROLE_STYLES: Record<string, { bg: string; color: string }> = {

View File

@ -8,7 +8,7 @@ export type EventType = (typeof EVENT_TYPES)[number];
export const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'] as const; export const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'] as const;
export type EventStatus = (typeof EVENT_STATUS)[number]; export type EventStatus = (typeof EVENT_STATUS)[number];
export const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled'] as const; export const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled', 'rejected'] as const;
export type ParticipantStatus = (typeof PARTICIPANT_STATUS)[number]; export type ParticipantStatus = (typeof PARTICIPANT_STATUS)[number];
export const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'] as const; export const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'] as const;

View File

@ -47,6 +47,13 @@ export interface BadgeCustomImagePayload {
imageUrl: string; imageUrl: string;
} }
export interface BadgeBackgroundImageLayout {
x: number;
y: number;
w: number;
h: number;
}
export interface CreateBadgeBatchPayload { export interface CreateBadgeBatchPayload {
name: string; name: string;
organization?: string; organization?: string;
@ -61,6 +68,7 @@ export interface CreateBadgeBatchPayload {
participantListId?: string; participantListId?: string;
backgroundMode: 'image' | 'color'; backgroundMode: 'image' | 'color';
backgroundImageUrl?: string; backgroundImageUrl?: string;
backgroundImageLayout?: BadgeBackgroundImageLayout;
backgroundColor?: string; backgroundColor?: string;
fields: string[]; fields: string[];
layout?: BadgeLayoutElementPayload[]; layout?: BadgeLayoutElementPayload[];

View File

@ -29,8 +29,9 @@ export const eventsApi = {
); );
}, },
get(id: string) { get(id: string, opts?: { enrich?: boolean }) {
return api.get<Event>(`/events/${id}`); const q = opts?.enrich ? '?enrich=1' : '';
return api.get<Event>(`/events/${id}${q}`);
}, },
create(payload: Partial<Event>) { create(payload: Partial<Event>) {

View File

@ -45,7 +45,7 @@ export { formsApi } from './forms';
export { rolesApi } from './roles'; export { rolesApi } from './roles';
export { usersApi } from './users'; export { usersApi } from './users';
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks'; export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
export { sessionsApi, type ApiSession, type SessionPayload } from './sessions'; export { sessionsApi, type ApiSession, type SessionPayload, type SessionTopic } from './sessions';
export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff'; export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff';
export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls'; export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
export { export {
@ -79,6 +79,7 @@ export {
type MessagingCampaign, type MessagingCampaign,
type CampaignStatus, type CampaignStatus,
type CampaignContent, type CampaignContent,
type CampaignRecipients,
type WhatsAppQueueJobSummary, type WhatsAppQueueJobSummary,
} from './campaigns'; } from './campaigns';
export { subscriptionApi, loadRazorpayScript } from './subscription'; export { subscriptionApi, loadRazorpayScript } from './subscription';

View File

@ -27,6 +27,8 @@ interface ListAllParams {
limit?: number; limit?: number;
q?: string; q?: string;
tag?: string; tag?: string;
/** When true, list soft-deleted participants only. */
deleted?: boolean;
} }
export const participantsApi = { export const participantsApi = {
@ -37,6 +39,7 @@ export const participantsApi = {
sp.set('limit', String(params?.limit ?? 20)); sp.set('limit', String(params?.limit ?? 20));
if (params?.q?.trim()) sp.set('q', params.q.trim()); if (params?.q?.trim()) sp.set('q', params.q.trim());
if (params?.tag?.trim()) sp.set('tag', params.tag.trim()); if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
if (params?.deleted) sp.set('deleted', 'true');
const query = sp.toString(); const query = sp.toString();
return api.get<{ participants: ParticipantWithEvent[]; total: number; page: number; limit: number }>( return api.get<{ participants: ParticipantWithEvent[]; total: number; page: number; limit: number }>(
`/participants${query ? `?${query}` : ''}` `/participants${query ? `?${query}` : ''}`
@ -127,23 +130,39 @@ export const participantsApi = {
return api.put<Participant>(`/participants/${id}`, payload); return api.put<Participant>(`/participants/${id}`, payload);
}, },
/** Soft-delete participant (or remove from event via eventId — last membership soft-deletes). */
remove(id: string, eventId: string) { remove(id: string, eventId: string) {
const q = new URLSearchParams({ event_id: eventId }); const q = new URLSearchParams({ event_id: eventId });
return api.delete<unknown>(`/participants/${id}?${q.toString()}`); return api.delete<unknown>(`/participants/${id}?${q.toString()}`);
}, },
/** Remove participant record entirely (all events). */ /** Soft-delete participant record (all events). */
deleteOne(id: string) { deleteOne(id: string) {
return api.delete<unknown>(`/participants/${id}`); return api.delete<unknown>(`/participants/${id}`);
}, },
/** Restore a soft-deleted participant. */
restore(id: string) {
return api.post<Participant>(`/participants/${id}/restore`, {});
},
checkIn(id: string, eventId: string) { checkIn(id: string, eventId: string) {
const q = new URLSearchParams({ event_id: eventId }); const q = new URLSearchParams({ event_id: eventId });
return api.post<Participant>(`/participants/${id}/check-in?${q.toString()}`, {}); return api.post<Participant>(`/participants/${id}/check-in?${q.toString()}`, {});
}, },
confirm(id: string, eventId: string) {
const q = new URLSearchParams({ event_id: eventId });
return api.post<Participant>(`/participants/${id}/confirm?${q.toString()}`, {});
},
reject(id: string, eventId: string) {
const q = new URLSearchParams({ event_id: eventId });
return api.post<Participant>(`/participants/${id}/reject?${q.toString()}`, {});
},
/** Full event export (all participants). Triggers browser download. */ /** Full event export (all participants). Triggers browser download. */
async downloadExport(eventId: string, fileName = 'participants-export.csv') { async downloadExport(eventId: string, fileName?: string) {
const res = await fetchWithAuth(`/events/${eventId}/participants/export`, { method: 'GET' }); const res = await fetchWithAuth(`/events/${eventId}/participants/export`, { method: 'GET' });
if (!res.ok) { if (!res.ok) {
let msg = res.statusText; let msg = res.statusText;
@ -155,11 +174,12 @@ export const participantsApi = {
} }
return { ok: false as const, error: msg }; return { ok: false as const, error: msg };
} }
const fromHeader = parseContentDispositionFilename(res.headers.get('Content-Disposition'));
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = fileName; a.download = fileName || fromHeader || 'Event - Participants.csv';
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
return { ok: true as const }; return { ok: true as const };
@ -224,3 +244,20 @@ export const participantsApi = {
}>('/participants/import', payload); }>('/participants/import', payload);
}, },
}; };
function parseContentDispositionFilename(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=UTF-8''([^;\n]+)/i.exec(header);
if (star?.[1]) {
try {
return decodeURIComponent(star[1].trim());
} catch {
/* fall through */
}
}
const quoted = /filename="([^"]+)"/i.exec(header);
if (quoted?.[1]) return quoted[1];
const bare = /filename=([^;\n]+)/i.exec(header);
if (bare?.[1]) return bare[1].trim().replace(/^"|"$/g, '');
return null;
}

View File

@ -32,9 +32,15 @@ export interface ApiSession {
enableQnA?: boolean; enableQnA?: boolean;
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */ /** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
enableQnP?: boolean; enableQnP?: boolean;
/** Mongo id of portfolio feedback form, or omit/null for default attendee UI */ /** Nested session topics for hierarchical programmes */
audienceFeedbackForm?: string | null; topics?: SessionTopic[];
audienceRatingsForm?: string | null; }
export interface SessionTopic {
type?: string;
duration?: string;
title?: string;
role?: string;
} }
export type SessionPayload = { export type SessionPayload = {
@ -59,6 +65,7 @@ export type SessionPayload = {
audienceRatingsForm?: string | null; audienceRatingsForm?: string | null;
enableQnA?: boolean; enableQnA?: boolean;
enableQnP?: boolean; enableQnP?: boolean;
topics?: SessionTopic[];
}; };
export const sessionsApi = { export const sessionsApi = {

View File

@ -75,11 +75,23 @@ export interface User {
assignedEvents?: string[]; assignedEvents?: string[];
} }
/** Primary org admin (super_admin) on platform-admin org list/detail */
export interface OrganizationPrimaryAdmin {
_id: string;
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
role?: string;
}
export interface OrganizationRecord { export interface OrganizationRecord {
_id: string; _id: string;
name: string; name: string;
/** Populated by platform-admin org list/detail */ /** Populated by platform-admin org list/detail */
userCount?: number; userCount?: number;
/** First org super_admin user associated with this tenant */
primaryAdmin?: OrganizationPrimaryAdmin | null;
description?: string; description?: string;
email?: string; email?: string;
phone?: string; phone?: string;
@ -273,6 +285,15 @@ export interface EventMicrosite {
eventName?: string | null; eventName?: string | null;
} }
/** SPA microsite theme presets (`website.theme` on public microsite payload). */
export type WebsiteThemeMode = 'simple' | 'gradient';
export interface WebsiteThemeConfig {
theme: WebsiteThemeMode;
color: string;
font?: string | null;
}
export interface EventWebsiteSettings { export interface EventWebsiteSettings {
enabled?: boolean; enabled?: boolean;
folder?: string; folder?: string;
@ -293,6 +314,8 @@ export interface EventWebsiteSettings {
seoDescription?: string; seoDescription?: string;
/** Visual editor markup; canonical with publish (also mirrored legacy on Home EventPage). */ /** Visual editor markup; canonical with publish (also mirrored legacy on Home EventPage). */
micrositeHtml?: string; micrositeHtml?: string;
/** Luma-style theme: simple/gradient + color + font */
theme?: WebsiteThemeConfig | null;
} }
/** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */ /** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */
@ -313,6 +336,12 @@ export interface ActivityStats {
participantCount: number; participantCount: number;
quizPollCount: number; quizPollCount: number;
responseCount: number; responseCount: number;
/** Average star rating across all event feedback/ratings forms */
eventRatingAverage?: number;
eventRatingCount?: number;
/** Average star rating for the activity's primary session ratings form */
sessionRatingAverage?: number;
sessionRatingCount?: number;
} }
export interface Event { export interface Event {
@ -478,6 +507,13 @@ export interface DesignTemplate {
variables?: DesignTemplateVariable[]; variables?: DesignTemplateVariable[];
organization?: string; organization?: string;
settings?: { isPublic?: boolean; isActive?: boolean; canDuplicate?: boolean }; settings?: { isPublic?: boolean; isActive?: boolean; canDuplicate?: boolean };
isSystemTemplate?: boolean;
isGlobalTemplate?: boolean;
isOrgOverride?: boolean;
globalTemplateKey?: string;
systemTemplateKey?: string;
derivedFromTemplate?: string;
canDelete?: boolean;
usedBy?: string[]; usedBy?: string[];
createdBy?: string; createdBy?: string;
createdAt?: string; createdAt?: string;
@ -527,6 +563,8 @@ export interface FormField {
mapsTo?: string; mapsTo?: string;
customMapsKey?: string; customMapsKey?: string;
isFixed?: boolean; isFixed?: boolean;
/** Registration: add submitted value(s) as participant tags. */
convertToTag?: boolean;
} }
export interface FormSettings { export interface FormSettings {
@ -541,8 +579,20 @@ export interface FormSettings {
paymentEnabled?: boolean; paymentEnabled?: boolean;
paymentAmount?: string | number; paymentAmount?: string | number;
sendConfirmationEmail?: boolean; sendConfirmationEmail?: boolean;
/** Emailer DesignTemplate id used for registration confirmation. */
confirmationEmailTemplateId?: string | null;
onSubmitAddTags?: string[]; onSubmitAddTags?: string[];
onSubmitParticipantListId?: string; onSubmitParticipantListId?: string;
allowDuplicateEmail?: boolean;
allowDuplicatePhone?: boolean;
formClosedMessage?: string;
submitErrorMessage?: string;
captchaFailedMessage?: string;
registrationClosedMessage?: string;
registrationDeadlineMessage?: string;
registrationFullMessage?: string;
duplicateEmailMessage?: string;
duplicatePhoneMessage?: string;
eventHeader?: { eventHeader?: {
showLogo?: boolean; showLogo?: boolean;
showTitle?: boolean; showTitle?: boolean;
@ -601,6 +651,14 @@ export interface PublicFormDefinition {
showThankYou?: boolean; showThankYou?: boolean;
collectEmail?: boolean; collectEmail?: boolean;
allowAnonymous?: boolean; allowAnonymous?: boolean;
formClosedMessage?: string;
submitErrorMessage?: string;
captchaFailedMessage?: string;
registrationClosedMessage?: string;
registrationDeadlineMessage?: string;
registrationFullMessage?: string;
duplicateEmailMessage?: string;
duplicatePhoneMessage?: string;
eventHeader?: FormSettings['eventHeader']; eventHeader?: FormSettings['eventHeader'];
}; };
} }
@ -795,7 +853,9 @@ export type MicrositeV2FieldType =
| 'image' | 'image'
| 'video' | 'video'
| 'select' | 'select'
| 'array'; | 'array'
| 'date'
| 'time';
export interface MicrositeV2FieldShowIf { export interface MicrositeV2FieldShowIf {
field: string; field: string;

View File

@ -355,7 +355,11 @@ export function PublicFormView() {
await formsApi.submit(formId, { data }); await formsApi.submit(formId, { data });
setDone(true); setDone(true);
} catch (e) { } catch (e) {
setSubmitError(e instanceof Error ? e.message : "Submit failed."); setSubmitError(
e instanceof Error
? e.message
: form.settings?.submitErrorMessage?.trim() || "Submit failed.",
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -398,6 +402,7 @@ export function PublicFormView() {
} }
if (done) { if (done) {
const showThanks = form.settings?.showThankYou !== false;
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response."; const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
const ev = form.event; const ev = form.event;
return ( return (
@ -436,6 +441,7 @@ export function PublicFormView() {
> >
{form.name} {form.name}
</h1> </h1>
{showThanks ? (
<p <p
style={{ style={{
margin: 0, margin: 0,
@ -451,6 +457,7 @@ export function PublicFormView() {
> >
{msg} {msg}
</p> </p>
) : null}
</div> </div>
</div> </div>
); );

View File

@ -10,7 +10,6 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }