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"
/>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" crossorigin src="/assets/index-DJFhkFks.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
<script type="module" crossorigin src="/assets/index-Dj8crrlq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-1SgcqEoJ.css">
</head>
<body>

View File

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

View File

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

View File

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

View File

@ -54,7 +54,6 @@ const PORTFOLIO_NAV: NavItem[] = [
id: "design", label: "Design", icon: <Palette size={16} />,
children: [
{ id: "design-brochure", label: "Brochure" },
{ id: "design-microsite", label: "Microsite Templates" },
{ id: "design-badges", label: "Badges" },
{ id: "design-emailers", label: "Emailers" },
{ id: "design-whatsapp-templates", label: "WhatsApp Templates" },
@ -459,28 +458,154 @@ function BottomUser({ collapsed, onNavigate }: { collapsed: boolean; onNavigate:
)}
{collapsed ? (
<Tooltip label={`${displayName} · Log out`}>
<button type="button" onClick={handleLogout} 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>
<>
<Tooltip label={`${displayName} · Settings`}>
<button
type="button"
onClick={() => onNavigate("settings")}
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>
</button>
</Tooltip>
) : (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px", borderRadius: "var(--radius-button)" }}>
<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 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>
<button type="button" onClick={handleLogout} title="Log out"
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px", flexShrink: 0 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<button
type="button"
onClick={handleLogout}
title="Log out"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--muted)",
display: "flex",
padding: "2px",
flexShrink: 0,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<LogOut size={13} />
</button>
</div>

View File

@ -1,16 +1,17 @@
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 { useAuth } from "../contexts/AuthContext";
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 { portfolioListOrganizationId } from "../../services/api/scopedOrganization";
import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents";
import {
doSearch, SUGGESTED_SHORTCUTS,
STATUS_BADGE, LOC_ICON,
doSearch,
STATUS_BADGE,
mapApiEventToSearchEvent,
SearchEvent, SearchPage, SearchAction, SearchResults,
ALL_EVENTS,
} from "./search/searchIndex";
/* ─────────────────────────────────────────
@ -76,72 +77,9 @@ function SearchDropdown({
</div>
);
/* ── Suggested shortcuts (empty query) ── */
/* ── Empty query: no menu (avoid static Suggested / Recent Events panel) ── */
if (!trimmed) {
return (
<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>
);
return null;
}
/* ── 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 }) {
const { user } = useAuth();
const { setActiveNav } = useAppNav();
const name = userDisplayName(user ?? null);
const initials = userInitials(user ?? null);
return (
<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" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
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[]>([]);
/** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */
const [orgDataVersion, setOrgDataVersion] = useState(0);
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isTablet = bp === "tablet";
const listOrgId = portfolioListOrganizationId(user);
const searchContainerRef = useRef<HTMLDivElement>(null);
const desktopInputRef = 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 ── */
const results: SearchResults | null = useMemo(
() => query.trim() ? doSearch(query) : null,
[query],
() => (query.trim() ? doSearch(query, orgSearchEvents) : null),
[query, orgSearchEvents],
);
/* ── Flat items for keyboard nav ── */
@ -498,13 +590,13 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
setTimeout(() => mobileInputRef.current?.focus(), 50);
} else {
desktopInputRef.current?.focus();
setDropdownOpen(true);
if (query.trim()) setDropdownOpen(true);
}
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isMobile]);
}, [isMobile, query]);
/* ── Keyboard navigation inside input ── */
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@ -617,14 +709,14 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
type="text"
value={query}
placeholder="Search events, pages, actions…"
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(true); }}
onFocus={() => { setFocused(true); setDropdownOpen(true); }}
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(!!e.target.value.trim()); }}
onFocus={() => { setFocused(true); if (query.trim()) setDropdownOpen(true); }}
onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown}
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
/>
{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} />
</button>
) : (
@ -634,8 +726,8 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
)}
</div>
{/* ── Dropdown ── */}
{dropdownOpen && (
{/* ── Dropdown (only when there is a query) ── */}
{dropdownOpen && query.trim() && (
<div
style={{
position: "absolute", top: "32px", left: 0,
@ -729,16 +821,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
</button>
)}
{/* Bell */}
<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>
<NotificationsBell />
<div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} />
@ -786,18 +869,20 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
</button>
</div>
{/* Dropdown results */}
<div style={{ overflowY: "auto", flex: 1 }}>
<SearchDropdown
query={query}
results={results}
activeIndex={activeIndex}
onEventClick={handleEventClick}
onPageClick={handlePageClick}
onActionClick={handleActionClick}
onViewAll={handleViewAll}
/>
</div>
{/* Dropdown results — only when typing */}
{query.trim() ? (
<div style={{ overflowY: "auto", flex: 1 }}>
<SearchDropdown
query={query}
results={results}
activeIndex={activeIndex}
onEventClick={handleEventClick}
onPageClick={handlePageClick}
onActionClick={handleActionClick}
onViewAll={handleViewAll}
/>
</div>
) : null}
</div>
</div>
)}

View File

@ -4,7 +4,7 @@
import React, { useState, useMemo, useEffect, useCallback } from "react";
import {
Search, X, LayoutGrid, List, Calendar as CalIcon,
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2,
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, Star,
} from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext";
@ -22,6 +22,7 @@ import {
} from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
import { formatActivityRating, weightedActivityRating } from "./activityRatingDisplay";
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return modalQuestionToApi(q, kind);
@ -41,6 +42,10 @@ interface ActivityRow {
participants: number;
questions: number;
responses: number;
eventRatingAverage: number;
eventRatingCount: number;
sessionRatingAverage: number;
sessionRatingCount: number;
subtype: "qna" | "quiz" | "poll";
raw: Event;
}
@ -131,6 +136,10 @@ function eventToRow(ev: Event): ActivityRow {
participants: st?.participantCount ?? 0,
questions: st?.questionCount ?? 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",
raw: ev,
};
@ -391,11 +400,19 @@ export function ActivitiesPage({
const totals = useMemo(() => {
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 {
count: base.length,
active: base.filter(a => a.status === "Active").length,
participants: base.reduce((s, a) => s + a.participants, 0),
responses: base.reduce((s, a) => s + a.responses, 0),
eventRating,
sessionRating,
};
}, [rows]);
@ -474,11 +491,23 @@ export function ActivitiesPage({
{!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="Active" value={String(totals.active)} color="#15803d" />
<StatCard label="Total Participants" value={String(totals.participants)} />
<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 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 (
<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" }}>
@ -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" }}>
{label}
</div>
{sublabel ? (
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "6px" }}>
{sublabel}
</div>
) : null}
</div>
);
}
@ -753,7 +787,7 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
</p>
) : 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" }}>
<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>
@ -766,6 +800,18 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
<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>
</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>
);
@ -802,10 +848,16 @@ function ActivityListRow({ activity, isLast, onClick }: { activity: ActivityRow;
{activity.type}
</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)" }}>
{activity.participants} participants
</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>
<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).
*/
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 { useBreakpoint } from "../../hooks/useBreakpoint";
import { eventsApi } from "../../../services/api/events";
@ -23,6 +23,7 @@ import {
} from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal";
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
import { formatActivityRating } from "./activityRatingDisplay";
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
return modalQuestionToApi(q, kind);
@ -61,7 +62,7 @@ export function ActivityDetailPage() {
setLoadError(null);
try {
const [evRes, sesRes] = await Promise.all([
eventsApi.get(selectedEventId),
eventsApi.get(selectedEventId, { enrich: true }),
sessionsApi.list(selectedEventId),
]);
if (evRes.error || !evRes.data) {
@ -105,6 +106,7 @@ export function ActivityDetailPage() {
}, [tabs, activeTab]);
const progress = event ? activityProgress(event) : 0;
const activityStats = event?.activityStats;
const enableQnAOn = session ? effectiveEnableQnA(session) : true;
const enableQnPOn = session ? effectiveEnableQnP(session) : true;
const subtype = event?.activitySubtype || "qna";
@ -272,6 +274,35 @@ export function ActivityDetailPage() {
</button>
</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={{ display: "flex", gap: isMobile ? "8px" : "0", flexWrap: "wrap" }}>
{tabs.map((tab) => (
@ -435,3 +466,50 @@ export function ActivityDetailPage() {
</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>Open</option>
<option>Invite Only</option>
<option>Approval Required</option>
</select>
</SelectWrap>
</Field>
</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>
<Label text="Verify Email" />
<RadioGroup
@ -538,6 +550,8 @@ export function ActivityEventSettings({
]}
/>
</Field>
</Row>
<Row>
<Field>
<Label text="Registration Deadline" />
<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 { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search } from "lucide-react";
import { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search, Trash2 } from "lucide-react";
import {
tracksApi,
sessionsApi,
@ -8,6 +8,7 @@ import {
type ApiTrack,
type ApiSession,
type SessionPayload,
type SessionTopic,
type Speaker,
} from "../../../services/api";
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 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[] {
if (!s?.speakers?.length) return [];
return s.speakers.map((sp) =>
@ -201,6 +234,7 @@ export function AgendaSessionPanel({
const [endLocal, setEndLocal] = useState(session?.endTime ? toDatetimeLocalValue(session.endTime) : def.end);
const [description, setDescription] = useState(session?.description || "");
const [topics, setTopics] = useState<TopicRow[]>(() => topicsFromSession(session));
const audienceOn = (v: boolean | undefined) => v !== false;
const [audienceFeedbackEnabled, setAudienceFeedbackEnabled] = useState(() =>
@ -341,6 +375,15 @@ export function AgendaSessionPanel({
const [err2, setErr2] = useState<SectionErr>({});
const [err3, setErr3] = 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 e: SectionErr = {};
@ -374,7 +417,15 @@ export function AgendaSessionPanel({
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 e: SectionErr = {};
@ -406,8 +457,9 @@ export function AgendaSessionPanel({
if (section === 2 && !validate2()) return;
if (section === 3 && !validate3()) return;
if (section === 4 && !validate4()) return;
if (section === 5 && !validate5()) return;
setPassedSections((prev) => new Set([...prev, section]));
if (section < 4) setOpenSection(section + 1);
if (section < 5) setOpenSection(section + 1);
};
const submit = async () => {
@ -421,10 +473,13 @@ export function AgendaSessionPanel({
if (!validate1()) setOpenSection(1);
else if (!validate2()) setOpenSection(2);
else if (!validate3()) setOpenSection(3);
else setOpenSection(4);
else if (!validate4()) setOpenSection(4);
else setOpenSection(5);
return;
}
const normalizedTopics = normalizeTopicsForPayload(topics);
const payload: SessionPayload = {
title: title.trim(),
description: description.trim() || undefined,
@ -434,6 +489,7 @@ export function AgendaSessionPanel({
status: UI_TO_API_STATUS[status],
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
speakers: selectedSpeakerIds,
topics: normalizedTopics.length ? normalizedTopics : [],
audienceFeedbackEnabled,
audienceRatingsEnabled,
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: 2, title: "Session details", subtitle: "Title, format, and status." },
{ 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 = () => (
@ -750,6 +807,105 @@ export function AgendaSessionPanel({
)}
{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>
<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)" }}>
{sec.n < 4 ? (
{sec.n < 5 ? (
<button
type="button"
onClick={() => tryNext(sec.n)}

View File

@ -14,6 +14,7 @@ import {
X,
} from "lucide-react";
import { useAppNav } from "../../contexts/AppNavContext";
import { usePermission } from "../../contexts/PermissionContext";
import { attendancesApi } from "../../../services/api";
import type {
AttendanceRecord,
@ -53,6 +54,10 @@ const emptyForm: FormState = {
export function AttendancePage() {
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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -75,7 +80,7 @@ export function AttendancePage() {
const selected = sessions.find((s) => s._id === selectedId) ?? null;
const loadSessions = useCallback(async () => {
if (!selectedEventId) return;
if (!selectedEventId || !canReadAttendance) return;
setLoading(true);
setError(null);
const res = await attendancesApi.list(selectedEventId);
@ -85,7 +90,7 @@ export function AttendancePage() {
return;
}
setSessions(res.data?.attendances ?? []);
}, [selectedEventId]);
}, [selectedEventId, canReadAttendance]);
useEffect(() => {
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
<div
@ -252,24 +265,26 @@ export function AttendancePage() {
Attendance
</h1>
</div>
<button
type="button"
onClick={openCreate}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
background: "var(--primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-button)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
<Plus size={14} /> Add session
</button>
{canWriteAttendance && (
<button
type="button"
onClick={openCreate}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 14px",
background: "var(--primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius-button)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
<Plus size={14} /> Add session
</button>
)}
</div>
{error && (
@ -370,14 +385,16 @@ export function AttendancePage() {
)}
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={() => openEdit(selected)}
title="Edit"
style={iconBtn}
>
<Pencil size={14} />
</button>
{canWriteAttendance && (
<button
type="button"
onClick={() => openEdit(selected)}
title="Edit"
style={iconBtn}
>
<Pencil size={14} />
</button>
)}
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
<Download size={14} />
</button>
@ -390,17 +407,21 @@ export function AttendancePage() {
>
<Link2 size={14} />
</button>
<button
type="button"
onClick={() => regenerateLink(selected._id)}
title="Regenerate link"
style={iconBtn}
>
<RefreshCw size={14} />
</button>
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
<Trash2 size={14} />
</button>
{canWriteAttendance && (
<button
type="button"
onClick={() => regenerateLink(selected._id)}
title="Regenerate link"
style={iconBtn}
>
<RefreshCw size={14} />
</button>
)}
{canDeleteAttendance && (
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
<Trash2 size={14} />
</button>
)}
</div>
</div>
@ -449,11 +470,11 @@ export function AttendancePage() {
</div>
<button
type="button"
disabled={!canCheckIn || checkInBusy === p._id}
disabled={!canWriteAttendance || !canCheckIn || checkInBusy === p._id}
onClick={() => handleCheckIn(p._id)}
style={{
...checkInBtn,
opacity: canCheckIn ? 1 : 0.5,
opacity: canWriteAttendance && canCheckIn ? 1 : 0.5,
}}
>
<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 { Calendar, MapPin, ExternalLink, Pencil } from "lucide-react";
import { Calendar, MapPin, ExternalLink, Pencil, Settings } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import type { Event } from "../../../services/api/types";
import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl";
@ -63,11 +63,13 @@ export function EventHeader({
event,
loading = false,
onEditEvent,
onWebsiteSettings,
canEdit = true,
}: {
event: Event | null;
loading?: boolean;
onEditEvent?: () => void;
onWebsiteSettings?: () => void;
canEdit?: boolean;
}) {
const bp = useBreakpoint();
@ -165,6 +167,32 @@ export function EventHeader({
<ExternalLink size={12} /> View Website
</a>
) : 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
type="button"
disabled={!canEdit || !event}

View File

@ -1,5 +1,5 @@
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 { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
@ -9,14 +9,17 @@ import { eventStaffApi } from "../../../services/api/eventStaff";
import { quizPollsApi } from "../../../services/api/quizPolls";
import { badgeBatchesApi } from "../../../services/api/badgeBatches";
import { eventTicketsApi } from "../../../services/api/eventTickets";
import { campaignsApi } from "../../../services/api/campaigns";
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
import { CreateFormPanel } from "../panels/CreateFormPanel";
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
import { ShareFormPanel } from "../forms/ShareFormPanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel";
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
import type { Question } from "../quiz/AddQuestionModal";
@ -201,8 +204,24 @@ export function EventHomeModules() {
const isSuperAdmin = user?.role === "super_admin";
const canReadForms = canModule("forms", "read");
const canWriteForms = canModule("forms", "write");
const canReadSessions = canModule("sessions", "read");
const canWriteSessions = canModule("sessions", "write");
const canReadUserMgmt = canModule("user_management", "read");
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 isMobile = bp === "mobile";
const isTablet = bp === "tablet";
@ -244,6 +263,10 @@ export function EventHomeModules() {
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
const [webCheckInLoading, setWebCheckInLoading] = 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(() => {
if (!selectedEventId) {
@ -450,6 +473,28 @@ export function EventHomeModules() {
void 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 = () => {
setShowAddBadge(false);
setDuplicateBadgeBatchId(null);
@ -528,6 +573,11 @@ export function EventHomeModules() {
: agendaLoading
? "Loading…"
: `${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
? "Select an event to configure web check-in"
: webCheckInLoading
@ -584,6 +634,7 @@ export function EventHomeModules() {
gap: "12px",
}}
>
{canReadSessions ? (
<ModuleCard
icon={<CalendarDays size={18} />}
title="Agenda"
@ -630,6 +681,7 @@ export function EventHomeModules() {
</>
}
/>
) : null}
{canReadForms ? eventForms.map((form) => {
const linkCopied = copiedFormLinkId === form._id;
return (
@ -776,6 +828,7 @@ export function EventHomeModules() {
}
/>
) : null}
{canReadUserMgmt ? (
<ModuleCard
icon={<ShieldCheck size={18} />}
title="Users & Permissions"
@ -824,6 +877,8 @@ export function EventHomeModules() {
</>
}
/>
) : null}
{canReadActivities ? (
<ModuleCard
icon={<BarChart2 size={18} />}
title="Activities"
@ -832,20 +887,20 @@ export function EventHomeModules() {
<>
<button
type="button"
disabled={actionsDisabled}
disabled={actionsDisabled || !canWriteActivities}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
}}
onClick={() => setShowAddPoll(true)}
onMouseEnter={(e) => {
if (actionsDisabled) return;
if (actionsDisabled || !canWriteActivities) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (actionsDisabled) return;
if (actionsDisabled || !canWriteActivities) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}}
@ -854,11 +909,11 @@ export function EventHomeModules() {
</button>
<button
type="button"
disabled={actionsDisabled}
disabled={actionsDisabled || !canWriteActivities}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
}}
onClick={() => setShowAddQuiz(true)}
>
@ -867,6 +922,8 @@ export function EventHomeModules() {
</>
}
/>
) : null}
{canReadBadges ? (
<ModuleCard
icon={<Tag size={18} />}
title="Tag & Badge Configuration"
@ -895,6 +952,7 @@ export function EventHomeModules() {
>
View Badges
</button>
{canWriteBadges ? (
<button
type="button"
disabled={actionsDisabled}
@ -907,9 +965,12 @@ export function EventHomeModules() {
>
Add Badge
</button>
) : null}
</>
}
/>
) : null}
{canReadTicketing ? (
<ModuleCard
icon={<Ticket size={18} />}
title="Ticket configuration"
@ -929,6 +990,8 @@ export function EventHomeModules() {
</button>
}
/>
) : null}
{canReadMicrosite ? (
<ModuleCard
icon={<Globe size={18} />}
title="Microsite"
@ -948,6 +1011,8 @@ export function EventHomeModules() {
</button>
}
/>
) : null}
{canReadRewards ? (
<ModuleCard
icon={<Trophy size={18} />}
title="Reward Points Config"
@ -967,6 +1032,8 @@ export function EventHomeModules() {
</button>
}
/>
) : null}
{canReadWebCheckIn ? (
<ModuleCard
icon={<Plus size={18} />}
title="Web check-in"
@ -976,12 +1043,12 @@ export function EventHomeModules() {
webCheckInEnabled ? (
<button
type="button"
disabled={actionsDisabled || webCheckInLoading}
disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
cursor:
actionsDisabled || webCheckInLoading
actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
? "not-allowed"
: "pointer",
}}
@ -992,12 +1059,12 @@ export function EventHomeModules() {
) : (
<button
type="button"
disabled={actionsDisabled || webCheckInLoading}
disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
cursor:
actionsDisabled || webCheckInLoading
actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
? "not-allowed"
: "pointer",
}}
@ -1008,6 +1075,8 @@ export function EventHomeModules() {
)
}
/>
) : null}
{canReadDocuments ? (
<ModuleCard
icon={<Upload size={18} />}
title="Upload Document"
@ -1027,6 +1096,52 @@ export function EventHomeModules() {
</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>
{formShareTarget ? (
@ -1127,6 +1242,23 @@ export function EventHomeModules() {
onClose={() => setShowDocumentsPanel(false)}
/>
) : 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,
Shield, Image, Layout, Tag, Mail,
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,
} from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
@ -46,6 +46,7 @@ import type {
EventMicrosite,
} from "../../../services/api";
import { LoadingState } from "../shared";
import { buildEmailerPreviewHtml } from "../../utils/emailerPreview";
import { ConfirmModal } from "../shared";
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
@ -147,6 +148,7 @@ function formatLastEdited(updatedAt?: string): string {
export function DesignPage({ subPage }: { subPage?: string }) {
const { user } = useAuth();
const { canModule } = usePermission();
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const activeTab = subPage === "design-brochure" ? "Brochure"
@ -156,6 +158,8 @@ export function DesignPage({ subPage }: { subPage?: string }) {
: "Brochure";
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
const isSuperAdmin = user?.role === "super_admin";
const canWriteEmailers = isSuperAdmin || canModule("email_outreach_campaigns", "write");
const canManageEmailers = apiType === "emailer" ? canWriteEmailers : isSuperAdmin;
const canManageMicrosite =
apiType === "microsite" &&
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") {
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()) {
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>';
@ -462,7 +469,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
<Upload size={13} /> Import
</button>
)}
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && (
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : canManageEmailers) && (
<button
onClick={handleCreate}
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
@ -507,7 +514,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
{templateIcons[activeTab]}
{hovered === t._id && (
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
{isSuperAdmin && (
{(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>
)}
{apiType === "brochure" ? (
@ -552,7 +559,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
{emailerPreviewBusyId === t._id ? "…" : "Preview"}
</button>
) : 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>
)}
</div>
@ -561,13 +568,21 @@ export function DesignPage({ subPage }: { subPage?: string }) {
<div style={{ padding: "10px 12px" }}>
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
<span style={{ 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>
</div>
</div>
</div>
))}
{isSuperAdmin && (
{canManageEmailers && (
<div
onClick={handleCreate}
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
@ -1070,7 +1085,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
? "Checked In"
: statusRaw === "confirmed"
? "Confirmed"
: "Pending";
: statusRaw === "rejected"
? "Rejected"
: "Pending";
const ev = p.event as { _id?: string; name?: string } | undefined;
const eventName = ev?.name ?? "—";
const id = String(p._id);
@ -1079,6 +1096,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
export function AllParticipantsPage() {
const { user } = useAuth();
const { canModule } = usePermission();
const canWriteParticipants = canModule("participants", "write");
const canDeleteParticipants = canModule("participants", "delete");
const listOrgId = portfolioListOrganizationId(user);
const isSuperAdmin = user?.role === "super_admin";
const { setActiveNav } = useAppNav();
@ -1089,6 +1109,7 @@ export function AllParticipantsPage() {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [activeTag, setActiveTag] = useState<string | null>(null);
const [showDeleted, setShowDeleted] = useState(false);
const [hovered, setHovered] = useState<string | null>(null);
const [focused, setFocused] = useState(false);
const [loading, setLoading] = useState(true);
@ -1100,6 +1121,7 @@ export function AllParticipantsPage() {
const [tagOptions, setTagOptions] = useState<string[]>([]);
const [editParticipantId, setEditParticipantId] = useState<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 {
@ -1131,17 +1153,18 @@ export function AllParticipantsPage() {
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
}, [searchInput]);
useEffect(() => { setPage(1); }, [search, activeTag]);
useEffect(() => { setPage(1); }, [search, activeTag, showDeleted]);
useEffect(() => {
if (!listOrgId) {
setTagOptions([]);
return;
}
if (showDeleted) return;
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
if (r.data?.tags?.length) setTagOptions(r.data.tags);
});
}, [listOrgId, listNonce]);
}, [listOrgId, listNonce, showDeleted]);
useEffect(() => {
if (!listOrgId) {
@ -1157,7 +1180,8 @@ export function AllParticipantsPage() {
page,
limit,
q: search || undefined,
tag: activeTag ?? undefined,
tag: showDeleted ? undefined : (activeTag ?? undefined),
deleted: showDeleted || undefined,
})
.then((res) => {
setLoading(false);
@ -1167,7 +1191,7 @@ export function AllParticipantsPage() {
}
})
.catch(() => setLoading(false));
}, [listOrgId, page, limit, search, activeTag, listNonce]);
}, [listOrgId, page, limit, search, activeTag, listNonce, showDeleted]);
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
@ -1214,12 +1238,24 @@ export function AllParticipantsPage() {
setImportMsg(res.error);
return;
}
setImportMsg(`Removed ${name}.`);
setImportMsg(`Moved ${name} to Deleted.`);
setSelectAllMatchingActive(false);
setSelectedIds((prev) => prev.filter((x) => x !== id));
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 (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{importMsg && (
@ -1233,25 +1269,57 @@ export function AllParticipantsPage() {
<p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
{total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))}
{total} {showDeleted ? "deleted" : "active"} in your organization · Page {page} of {Math.max(1, Math.ceil(total / limit))}
</p>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
<button type="button" disabled={exporting} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting ? 0.7 : 1 }}>
<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"}
</button>
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
<Upload size={13} /> Import
</button>
{canWriteParticipants && !showDeleted && (
<button type="button" onClick={() => { setImportMsg(null); setShowImport(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
<Upload size={13} /> Import
</button>
)}
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
Custom lists
</button>
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Create Custom List
</button>
{canWriteParticipants && (
<button type="button" onClick={() => setShowCustomList(true)} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Create Custom List
</button>
)}
</div>
</div>
{/* 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 */}
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<ParticipantSelectAllMatching
@ -1280,23 +1348,29 @@ export function AllParticipantsPage() {
)}
</div>
{!showDeleted && (
<ParticipantBulkActionBar
selectedCount={selectedIds.length}
busy={bulk.bulkBusy}
variant="portfolio"
onAddTags={() => void bulk.openBulkDialog("addTags")}
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
onAssignEvents={() => void bulk.openBulkDialog("assignEvents")}
onNewList={() => void bulk.openBulkDialog("newList")}
onAddToList={() => void bulk.openBulkDialog("addToList")}
onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }}
onAddTags={canWriteParticipants ? () => void bulk.openBulkDialog("addTags") : undefined}
onRemoveTags={canWriteParticipants ? () => void bulk.openBulkDialog("removeTags") : undefined}
onAssignEvents={canWriteParticipants ? () => void bulk.openBulkDialog("assignEvents") : undefined}
onNewList={canWriteParticipants ? () => void bulk.openBulkDialog("newList") : undefined}
onAddToList={canWriteParticipants ? () => void bulk.openBulkDialog("addToList") : undefined}
onDeleteSelected={
canDeleteParticipants
? () => { if (window.confirm(`Move ${selectedIds.length} participant(s) to Deleted?`)) void bulk.runBulkDelete(); }
: undefined
}
onClearSelection={clearSelection}
/>
)}
{/* Table */}
<ScrollTable minWidth={720}>
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
<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" }}>Email</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" }}>
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
No participants found
{showDeleted ? "No deleted participants" : "No participants found"}
</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
{(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
{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>
{(search || activeTag) && (
<button type="button" onClick={clearFilters}
@ -1327,14 +1403,14 @@ export function AllParticipantsPage() {
onMouseLeave={() => setHovered(null)}
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
>
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
<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={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
</div>
<div style={{ minWidth: 0 }}>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
<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>
<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>
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
<button type="button" title="Edit" onClick={() => setEditParticipantId(p.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}>
<Pencil size={13} />
</button>
<button type="button" title="Delete" onClick={() => setDeleteTarget({ id: p.id, name: p.name })} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: "30px", height: "30px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
<Trash2 size={13} />
</button>
{showDeleted ? (
canDeleteParticipants ? (
<button
type="button"
title="Restore participant"
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>
))}
@ -1420,7 +1516,7 @@ export function AllParticipantsPage() {
{deleteTarget && (
<ConfirmModal
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"
onConfirm={() => void confirmDeleteOne()}
onCancel={() => setDeleteTarget(null)}
@ -1435,6 +1531,7 @@ export function AllParticipantsPage() {
onToggleTag={bulk.toggleSelectedTag}
availableTags={tagOptions}
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
tagScope="global"
newListName={bulk.newListName}
onNewListNameChange={bulk.setNewListName}
newListDesc={bulk.newListDesc}
@ -1478,6 +1575,7 @@ function formatListMemberStatus(s: string | undefined): string {
if (raw === "checked_in") return "Checked In";
if (raw === "confirmed") return "Confirmed";
if (raw === "pending") return "Pending";
if (raw === "rejected") return "Rejected";
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>}
{msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>}
<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>
<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>
<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>
<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>
<Label text="Username" />
<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 ParticipantListRecord,
type DesignTemplate,
type CampaignRecipients,
} from "../../../services/api";
import { useAuth } from "../../contexts/AuthContext";
import { useAppNav } from "../../contexts/AppNavContext";
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";
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",
};
import { PARTICIPANT_STATUS, type ParticipantStatus } from "../../../config/constants";
function eventLabel(c: MessagingCampaign): string {
const e = c.event;
@ -36,6 +25,36 @@ function eventLabel(c: MessagingCampaign): string {
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() {
const { user } = useAuth();
const { setActiveNav, setSettingsInitialSection } = useAppNav();
@ -51,7 +70,9 @@ export function MessagingEmailPage() {
const [formEventId, setFormEventId] = useState("");
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("");
@ -98,7 +119,9 @@ export function MessagingEmailPage() {
setEditing(null);
setFormEventId(events[0]?._id || "");
setFormName("");
setRecipientSource("participant_list");
setFormListId(lists[0]?._id || "");
setFormStatus("");
setMsgMode("template");
setFormTemplateId(templates[0]?._id || "");
setFormSubject("");
@ -114,7 +137,16 @@ export function MessagingEmailPage() {
const eid = typeof c.event === "string" ? c.event : c.event?._id;
setFormEventId(eid || "");
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);
setMsgMode(hasTpl ? "template" : "manual");
setFormTemplateId(c.content?.template || "");
@ -125,15 +157,22 @@ export function MessagingEmailPage() {
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> => {
if (!formEventId || !formName.trim()) {
setErr("Event and campaign name are required.");
return null;
}
if (!formListId) {
setErr("Select a contact list.");
return null;
}
const recipients = resolveRecipients();
if (!recipients) return null;
setSaving(true);
setErr("");
if (msgMode === "template" && !formTemplateId) {
@ -149,7 +188,6 @@ export function MessagingEmailPage() {
return null;
}
}
const recipients = { type: "participant_list" as const, participantList: formListId };
const content =
msgMode === "template"
? { subject: "", body: "", template: formTemplateId }
@ -196,10 +234,8 @@ export function MessagingEmailPage() {
setErr("Event and campaign name are required.");
return;
}
if (!formListId) {
setErr("Select a contact list.");
return;
}
const recipients = resolveRecipients();
if (!recipients) return;
if (msgMode === "template" && !formTemplateId) {
setErr("Select an emailer template.");
return;
@ -213,7 +249,6 @@ export function MessagingEmailPage() {
}
setSaving(true);
setErr("");
const recipients = { type: "participant_list" as const, participantList: formListId };
const content =
msgMode === "template"
? { subject: "", body: "", template: formTemplateId }
@ -466,31 +501,92 @@ export function MessagingEmailPage() {
{!editing && (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Event</span>
<select style={IS} value={formEventId} onChange={(e) => setFormEventId(e.target.value)}>
<option value="">Select…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
<SelectWrap>
<select
style={SS}
value={formEventId}
onChange={(e) => setFormEventId(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="">Select…</option>
{events.map((ev) => (
<option key={ev._id} value={ev._id}>
{ev.name}
</option>
))}
</select>
</SelectWrap>
</label>
)}
<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)} placeholder="e.g. Registration reminder" />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
<select style={IS} value={formListId} onChange={(e) => setFormListId(e.target.value)}>
<option value="">Select…</option>
{lists.map((l) => (
<option key={l._id} value={l._id}>
{l.name}
</option>
))}
</select>
<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="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 }}>
<button type="button" onClick={() => setMsgMode("template")} style={msgMode === "template" ? btnPrimary : btnSecondary}>
@ -504,14 +600,22 @@ export function MessagingEmailPage() {
{msgMode === "template" && (
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span>
<select style={IS} value={formTemplateId} onChange={(e) => setFormTemplateId(e.target.value)}>
<option value="">Select…</option>
{templates.map((t) => (
<option key={t._id} value={t._id}>
{t.name}
</option>
))}
</select>
<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>
@ -522,7 +626,14 @@ export function MessagingEmailPage() {
<>
<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)} 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>
<div>
@ -541,7 +652,14 @@ export function MessagingEmailPage() {
<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)} />
<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 }}>

View File

@ -11,6 +11,7 @@ export const EMAIL_PLACEHOLDERS: { token: string; label: string }[] = [
{ token: "{{event.endDate}}", label: "Event end" },
{ token: "{{event.venue}}", label: "Venue" },
{ 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). */

View File

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

View File

@ -1,12 +1,13 @@
import React, { useRef, useState } from 'react';
import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
const SCHEDULE_SECTION_ID = 'event_schedule';
import { uploadMediaFile } from '../../../services/api/media';
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
import { Field, Label, HelperText } from '../panels/PanelShell';
import { FlatpickrDateInput } from '../ui/FlatpickrDatetimeInput';
import { getNestedValue, setNestedValue } from './configUtils';
const SCHEDULE_SECTION_ID = 'event_schedule';
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
@ -18,16 +19,154 @@ const inputStyle: React.CSSProperties = {
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({
field,
value,
onChange,
eventId,
arrayDepth = 0,
}: {
field: MicrositeV2FieldSchema;
value: unknown;
onChange: (v: unknown) => void;
eventId: string;
arrayDepth?: number;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = React.useState(false);
@ -44,7 +183,7 @@ function FieldInput({
}
};
switch (field.type) {
switch (resolveFieldType(field)) {
case 'boolean':
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}>
@ -86,6 +225,17 @@ function FieldInput({
))}
</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':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
@ -101,19 +251,14 @@ function FieldInput({
if (f) void handleUpload(f);
e.target.value = '';
}} />
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
style={{
...inputStyle,
cursor: uploading ? 'wait' : 'pointer',
width: 'auto',
alignSelf: 'flex-start',
}}
>
{uploading ? 'Uploading…' : value ? 'Change image' : 'Upload image'}
</button>
<MediaUploadActions
uploading={uploading}
hasValue={Boolean(value)}
uploadLabel="Upload image"
changeLabel="Change image"
onPickFile={() => fileRef.current?.click()}
onRemove={() => onChange('')}
/>
</div>
);
case 'video':
@ -131,19 +276,14 @@ function FieldInput({
if (f) void handleUpload(f);
e.target.value = '';
}} />
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
style={{
...inputStyle,
cursor: uploading ? 'wait' : 'pointer',
width: 'auto',
alignSelf: 'flex-start',
}}
>
{uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
</button>
<MediaUploadActions
uploading={uploading}
hasValue={Boolean(value)}
uploadLabel="Upload video"
changeLabel="Change video"
onPickFile={() => fileRef.current?.click()}
onRemove={() => onChange('')}
/>
</div>
);
case 'array':
@ -153,6 +293,7 @@ function FieldInput({
value={Array.isArray(value) ? value : []}
onChange={onChange}
eventId={eventId}
depth={arrayDepth}
/>
);
default:
@ -173,18 +314,32 @@ function ArrayFieldEditor({
value,
onChange,
eventId,
depth = 0,
}: {
field: MicrositeV2FieldSchema;
value: Record<string, unknown>[];
onChange: (v: Record<string, unknown>[]) => void;
eventId: string;
depth?: number;
}) {
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 row: Record<string, unknown> = {};
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]);
};
@ -199,7 +354,7 @@ function ArrayFieldEditor({
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', ...nestedStyle }}>
{value.map((row, idx) => (
<div
key={idx}
@ -213,7 +368,9 @@ function ArrayFieldEditor({
}}
>
<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)' }}>
<Trash2 size={14} />
</button>
@ -226,6 +383,7 @@ function ArrayFieldEditor({
value={row[f.key]}
onChange={(v) => updateRow(idx, f.key, v)}
eventId={eventId}
arrayDepth={depth + 1}
/>
</div>
))}
@ -249,7 +407,7 @@ function ArrayFieldEditor({
}}
>
<Plus size={14} />
Add {field.label || 'item'}
Add {isTopicsList ? 'Session Topic' : field.label || 'item'}
</button>
</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;
type: 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. */
sourceId?: string;
[key: string]: unknown;
}
export interface ScheduleTopic {
type?: string;
duration?: string;
title?: string;
role?: string;
}
export interface ScheduleDay {
label: string;
date: string;

View File

@ -1,11 +1,13 @@
/**
* 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 { ImageIcon } from "lucide-react";
import type { BadgeLayoutElement } from "./badgeLayout";
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import {
BADGE_BACKGROUND_IMAGE_ID,
applyBackgroundImageLayoutPatch,
applyElementSize,
clampElementPosition,
elementSizePct,
@ -39,11 +41,27 @@ type ResizeState = {
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({
aspectRatio,
backgroundMode,
backgroundColor,
backgroundImageUrl,
backgroundImageLayout,
layoutElements,
customImages,
badgeTypeLabel,
@ -51,31 +69,44 @@ export function BadgeDesignerPreview({
selectedId,
onSelect,
onLayoutChange,
onBackgroundImageLayoutChange,
surfaceRef,
}: {
aspectRatio: number;
badgeAspectRatio: number;
backgroundMode: "image" | "color";
backgroundColor: string;
backgroundImageUrl: string;
backgroundImageLayout: BadgeBackgroundImageLayout;
layoutElements: BadgeLayoutElement[];
customImages: { id: string; imageUrl: string }[];
badgeTypeLabel: string;
selectedId: string | null;
onSelect: (id: string | null) => 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 layoutRef = useRef(layoutElements);
const bgLayoutRef = useRef(backgroundImageLayout);
const onLayoutChangeRef = useRef(onLayoutChange);
const onBgLayoutChangeRef = useRef(onBackgroundImageLayoutChange);
const dragRef = useRef<DragState | null>(null);
const resizeRef = useRef<ResizeState | null>(null);
const bgDragRef = useRef<BgDragState | null>(null);
const bgResizeRef = useRef<BgResizeState | null>(null);
const qrUrlRef = useRef("");
const [, setTick] = React.useState(0);
layoutRef.current = layoutElements;
bgLayoutRef.current = backgroundImageLayout;
onLayoutChangeRef.current = onLayoutChange;
onBgLayoutChangeRef.current = onBackgroundImageLayoutChange;
const sample = samplePreviewData(badgeTypeLabel);
const hasBgImage = Boolean(backgroundImageUrl.trim());
const bgSelected = selectedId === BADGE_BACKGROUND_IMAGE_ID;
useEffect(() => {
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
@ -99,6 +130,36 @@ export function BadgeDesignerPreview({
const box = canvasRef.current?.getBoundingClientRect();
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;
if (drag) {
const el = layoutRef.current.find((x) => x.id === drag.id);
@ -106,7 +167,7 @@ export function BadgeDesignerPreview({
e.preventDefault();
const dx = ((e.clientX - drag.startX) / box.width) * 100;
const dy = ((e.clientY - drag.startY) / box.height) * 100;
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy);
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy, badgeAspectRatio);
patchElement(drag.id, pos);
return;
}
@ -126,6 +187,8 @@ export function BadgeDesignerPreview({
const end = () => {
dragRef.current = null;
resizeRef.current = null;
bgDragRef.current = null;
bgResizeRef.current = null;
};
window.addEventListener("pointermove", onMove, { passive: false });
@ -136,10 +199,10 @@ export function BadgeDesignerPreview({
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", end);
};
}, [patchElement]);
}, [patchElement, badgeAspectRatio]);
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
if (resizeRef.current) return;
if (resizeRef.current || bgDragRef.current || bgResizeRef.current) return;
e.preventDefault();
e.stopPropagation();
onSelect(el.id);
@ -165,52 +228,151 @@ export function BadgeDesignerPreview({
};
};
const bgStyle: React.CSSProperties =
backgroundMode === "image" && backgroundImageUrl.trim()
? {
backgroundImage: `url(${backgroundImageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: { backgroundColor };
const startBgDrag = (e: React.PointerEvent) => {
if (bgResizeRef.current || resizeRef.current) return;
e.preventDefault();
e.stopPropagation();
onSelect(BADGE_BACKGROUND_IMAGE_ID);
bgDragRef.current = {
startX: e.clientX,
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 (
<div style={{ padding: "8px 12px 16px 20px" }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 10px" }}>
Live preview — drag to move; drag corner handle to resize
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>
<div
ref={canvasRef}
role="presentation"
onClick={() => onSelect(null)}
ref={surfaceRef}
style={{
position: "relative",
width: "100%",
maxWidth: 420,
aspectRatio: String(aspectRatio),
position: "relative",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
boxShadow: "0 4px 20px rgba(0,0,0,0.12)",
overflow: "hidden",
containerType: "size",
touchAction: "none",
...bgStyle,
cursor: "default",
userSelect: "none",
}}
>
{layoutElements.map((el) => (
<PreviewElement
key={el.id}
el={el}
sample={sample}
customImages={customImages}
selected={selectedId === el.id}
qrUrl={qrUrlRef.current}
onDragStart={startDrag}
onResizeStart={startResize}
/>
))}
<div
ref={canvasRef}
role="presentation"
onClick={() => onSelect(null)}
style={{
width: "100%",
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",
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>
);
@ -252,6 +414,7 @@ function PreviewElement({
cursor: "grab",
boxSizing: "border-box",
touchAction: "none",
zIndex: 1,
...extra,
}}
>
@ -299,6 +462,7 @@ function PreviewElement({
touchAction: "none",
overflow: "hidden",
background: "#fff",
zIndex: 1,
}}
>
{url ? (
@ -481,6 +645,7 @@ function TextPreviewBlock({
boxSizing: "border-box",
textAlign: align,
touchAction: "none",
zIndex: 1,
}}
>
<div

View File

@ -45,6 +45,7 @@ export function BadgeFieldInspector({
customTextValue,
customImages,
badgeAspectRatio,
mediaEventId = "portfolio",
onChange,
onRemoveCustom,
onCustomImageUploaded,
@ -53,6 +54,7 @@ export function BadgeFieldInspector({
customTextValue?: string;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
mediaEventId?: string;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
@ -76,6 +78,7 @@ export function BadgeFieldInspector({
element={element}
customImages={customImages}
badgeAspectRatio={badgeAspectRatio}
mediaEventId={mediaEventId}
onChange={onChange}
onRemoveCustom={onRemoveCustom}
onCustomImageUploaded={onCustomImageUploaded}
@ -224,6 +227,7 @@ function ResizableInspector({
element,
customImages,
badgeAspectRatio,
mediaEventId = "portfolio",
onChange,
onRemoveCustom,
onCustomImageUploaded,
@ -231,6 +235,7 @@ function ResizableInspector({
element: BadgeLayoutElement;
customImages: { id: string; imageUrl: string }[];
badgeAspectRatio: number;
mediaEventId?: string;
onChange: (patch: Partial<BadgeLayoutElement>) => void;
onRemoveCustom?: () => void;
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
@ -250,7 +255,7 @@ function ResizableInspector({
setUploading(true);
try {
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) {
const w = element.w ?? 25;
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);

View File

@ -19,18 +19,21 @@ import {
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
import { BadgeFieldInspector } from "./BadgeFieldInspector";
import type { BadgeLayoutElement } from "./badgeLayout";
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import {
BADGE_BACKGROUND_IMAGE_ID,
MAX_CUSTOM_IMAGE_FIELDS,
MAX_CUSTOM_TEXT_FIELDS,
applyElementSize,
buildCustomImageElement,
clampElementPosition,
defaultBackgroundImageLayout,
defaultLayoutForFields,
heightPctForImageWidth,
newCustomImageId,
newCustomTextId,
readFileImageAspectRatio,
squareElementHeightPct,
syncLayoutWithFields,
} from "./badgeLayout";
import { formStateFromBadgeBatch } from "./badgeBatchFormState";
@ -179,9 +182,11 @@ export function CreateBadgePanel({
const [includeParticipants, setIncludeParticipants] = useState(true);
const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all");
const [participantListId, setParticipantListId] = useState("");
const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color");
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
const [backgroundImageLayout, setBackgroundImageLayout] = useState<BadgeBackgroundImageLayout>(
defaultBackgroundImageLayout,
);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
@ -199,6 +204,9 @@ export function CreateBadgePanel({
const [headerBusy, setHeaderBusy] = useState(false);
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
const badgeSurfaceRef = useRef<HTMLDivElement>(null);
const badgeInspectorRef = useRef<HTMLDivElement>(null);
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
const isDuplicate = Boolean(duplicateFromBatchId);
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
@ -208,6 +216,18 @@ export function CreateBadgePanel({
[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(() => {
return () => {
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
@ -220,14 +240,14 @@ export function CreateBadgePanel({
let merged = { ...el };
if (el.field === "qr") {
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) {
merged = { ...merged, h: el.w };
merged = { ...merged, h: squareElementHeightPct(el.w, aspectRatio) };
} else if (el.kind === "custom_image" && el.imageAspectRatio) {
const w = el.w ?? 25;
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
}
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) };
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y, aspectRatio) };
}),
);
}, [sizePreset, orientation, customW, customH, aspectRatio]);
@ -258,9 +278,9 @@ export function CreateBadgePanel({
setIncludeParticipants(s.includeParticipants);
setParticipantScope(s.participantScope);
setParticipantListId(s.participantListId);
setBackgroundMode(s.backgroundMode);
setBackgroundColor(s.backgroundColor);
setBackgroundImageUrl(s.backgroundImageUrl);
setBackgroundImageLayout(s.backgroundImageLayout);
setBackgroundPreviewUrl("");
setEnabledFields(s.enabledFields);
setLayoutElements(s.layoutElements);
@ -302,7 +322,7 @@ export function CreateBadgePanel({
: [...enabledFields, id];
setEnabledFields(next);
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);
};
@ -314,11 +334,13 @@ export function CreateBadgePanel({
const nextCustom = [...customTexts, { id, text }];
setCustomTexts(nextCustom);
setLayoutElements((prev) =>
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio),
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, "color", aspectRatio),
);
setSelectedElementId(id);
};
const badgeMediaEventId = formEventId.trim() || "portfolio";
const addCustomImage = async (file: File) => {
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
if (!file.type.startsWith("image/")) {
@ -330,7 +352,7 @@ export function CreateBadgePanel({
setErr("");
try {
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");
const nextImages = [...customImages, { id, imageUrl: r.url }];
setCustomImages(nextImages);
@ -398,11 +420,13 @@ export function CreateBadgePanel({
setBackgroundPreviewUrl(local);
setHeaderBusy(true);
setErr("");
const hadBgImage = Boolean(backgroundImageUrl.trim());
try {
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/backgrounds" });
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
if (r.url) {
setBackgroundImageUrl(r.url);
setBackgroundMode("image");
if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
}
} catch (e) {
setErr(e instanceof Error ? e.message : "Upload failed");
@ -434,10 +458,6 @@ export function CreateBadgePanel({
setErr("Select a contact list.");
return;
}
if (backgroundMode === "image" && !backgroundImageUrl.trim()) {
setErr("Upload a background image or switch to solid colour.");
return;
}
const hasCustom =
layoutElements.some((el) => el.kind === "custom_text") ||
layoutElements.some((el) => el.kind === "custom_image");
@ -495,8 +515,9 @@ export function CreateBadgePanel({
includeParticipants,
participantScope,
...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}),
backgroundMode,
backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "",
backgroundMode: backgroundImageUrl.trim() ? "image" : "color",
backgroundImageUrl: backgroundImageUrl.trim(),
...(backgroundImageUrl.trim() ? { backgroundImageLayout } : {}),
backgroundColor,
fields: enabledFields,
layout: layoutPayload,
@ -548,21 +569,25 @@ export function CreateBadgePanel({
<BadgeDesignerPreview
aspectRatio={aspectRatio}
badgeAspectRatio={aspectRatio}
backgroundMode={backgroundMode}
backgroundColor={backgroundColor}
backgroundImageUrl={displayBgUrl}
backgroundImageLayout={backgroundImageLayout}
layoutElements={layoutElements}
customImages={customImages}
badgeTypeLabel={badgeTypeLabel}
selectedId={selectedElementId}
onSelect={setSelectedElementId}
onLayoutChange={setLayoutElements}
onBackgroundImageLayoutChange={setBackgroundImageLayout}
surfaceRef={badgeSurfaceRef}
/>
<BadgeFieldInspector
element={selectedElement}
<div ref={badgeInspectorRef}>
<BadgeFieldInspector
element={selectedElementId === BADGE_BACKGROUND_IMAGE_ID ? null : selectedElement}
customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text}
customImages={customImages}
badgeAspectRatio={aspectRatio}
mediaEventId={badgeMediaEventId}
onChange={updateSelectedElement}
onRemoveCustom={
selectedElement?.kind === "custom_text"
@ -576,6 +601,7 @@ export function CreateBadgePanel({
updateSelectedElement({ imageUrl: url, imageAspectRatio });
}}
/>
</div>
</div>
</div>
@ -722,34 +748,53 @@ export function CreateBadgePanel({
nextLabel="Output"
>
<Field>
<Label text="Background" required />
<RadioGroup
name="bgMode"
value={backgroundMode}
onChange={(v) => setBackgroundMode(v as "image" | "color")}
options={[
{ label: "Solid colour", value: "color" },
{ label: "Image", value: "image" },
]}
/>
<Label text="Background colour" required />
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</div>
<HelperText text="Always applied behind badge fields. Shows through transparent areas of the background image." />
</Field>
{backgroundMode === "color" && (
<Field>
<Label text="Colour" />
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input type="color" value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} style={{ width: 48, height: 36, border: "none", cursor: "pointer" }} />
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
</div>
</Field>
)}
{backgroundMode === "image" && (
<Field>
<Label text="Background image" />
<input style={IS} value={backgroundImageUrl} onChange={(e) => setBackgroundImageUrl(e.target.value)} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
<Field>
<Label text="Background image (optional)" />
<input style={IS} value={backgroundImageUrl} onChange={(e) => {
const v = e.target.value;
setBackgroundImageUrl(v);
if (!v.trim()) {
setBackgroundPreviewUrl("");
if (selectedElementId === BADGE_BACKGROUND_IMAGE_ID) setSelectedElementId(null);
}
}} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
<BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
<HelperText text="Must be reachable by the server when the worker runs (public HTTPS)." />
</Field>
)}
{backgroundImageUrl.trim() ? (
<>
<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>
<Label text="Badge fields" />
<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: "Checked In", value: "checked_in" },
{ label: "Cancelled", value: "cancelled" },
{ label: "Rejected", value: "rejected" },
];
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 =
| { 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: "divider" };
@ -99,17 +99,25 @@ function publicFormHref(formId: string, eventId?: string): string {
function resolveCtaHref(
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 {
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
if (b.linkKind === "form") {
const f = targets.forms.find((x) => x.id === b.formId);
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
return href || "#";
if (b.formId) {
const f = targets.forms.find((x) => x.id === b.formId);
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") {
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 "#";
}
@ -195,6 +203,7 @@ function migrateBlocksFromContent(c: Record<string, unknown>): EmailerContentBlo
label: String(b.label ?? b.text ?? "Register"),
linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind,
formId: typeof b.formId === "string" ? b.formId : undefined,
formType: typeof b.formType === "string" ? b.formType : undefined,
micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined,
customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""),
});
@ -250,6 +259,7 @@ export function CreateEmailerPanel({
const [campaignEventId, setCampaignEventId] = useState("");
const [participantListId, setParticipantListId] = useState("");
const [campaignErr, setCampaignErr] = useState("");
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState(false);
const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => {
setForm((p) => ({ ...p, [k]: v }));
@ -285,6 +295,7 @@ export function CreateEmailerPanel({
void designTemplatesApi.get(templateId).then((res) => {
if (!res.data) return;
const t = res.data;
setEditingGlobalTemplate(Boolean(t.isSystemTemplate));
const c = (t.content || {}) as Record<string, unknown>;
const migrated = migrateBlocksFromContent(c);
const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent;
@ -397,6 +408,7 @@ export function CreateEmailerPanel({
category: form.emailerType || undefined,
content,
associatedEvent: form.associatedEvent.trim() || null,
organization: listOrgId || undefined,
};
const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload);
@ -437,7 +449,11 @@ export function CreateEmailerPanel({
onClose={onClose}
title={isEdit ? "Edit Emailer" : "Create Emailer"}
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}
totalSections={TOTAL}
ctaLabel={isEdit ? "Save changes" : "Save emailer"}

View File

@ -429,18 +429,33 @@ export function CreateEventPanel({
disabled={form.registrationRequired !== "Yes"}
>
<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>
</SelectWrap>
</Field>
</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>
<Label text="Verify Email" required />
<RadioGroup name="verifyEmail" value={form.verifyEmail}
onChange={v => f("verifyEmail", v as "Yes" | "No")}
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
</Field>
</Row>
<Row>
<Field>
<Label text="Registration Deadline" />
<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;
registrationRequired: "Yes" | "No";
registrationMode: string;
registrationApproval: "auto" | "manual";
verifyEmail: "Yes" | "No";
registrationDeadline: string;
authenticationType: string;
@ -459,6 +460,7 @@ const INITIAL: MergedForm = {
virtualLink: "",
registrationRequired: "No",
registrationMode: "",
registrationApproval: "auto",
verifyEmail: "Yes",
registrationDeadline: "",
authenticationType: "otp_email",
@ -859,6 +861,7 @@ export function CreateEventSessionPanel({
virtualLink: form.virtualLink,
registrationRequired: form.registrationRequired,
registrationMode: form.registrationMode,
registrationApproval: form.registrationApproval,
verifyEmail: form.verifyEmail,
registrationDeadline: form.registrationDeadline,
authenticationType: form.authenticationType,
@ -1593,12 +1596,24 @@ export function CreateEventSessionPanel({
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
<option>Open</option>
<option>Invite Only</option>
<option>Approval Required</option>
</select>
</SelectWrap>
</Field>
</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>
<Label text="Verify email" required />
<RadioGroup
@ -1611,6 +1626,8 @@ export function CreateEventSessionPanel({
]}
/>
</Field>
</Row>
<Row>
<Field>
<Label text="Registration deadline" />
<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 { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../hooks/usePermission";
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
import { eventsApi, formsApi, participantListsApi, designTemplatesApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord, DesignTemplate } from "../../../services/api";
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import {
EVENT_HEADER_TOGGLE_LABELS,
@ -44,6 +44,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
nextPatch.options = [];
nextPatch.mapsTo = "";
nextPatch.customMapsKey = "";
nextPatch.convertToTag = false;
return nextPatch;
}
if (OPTION_FIELD_TYPES.has(next)) {
@ -55,12 +56,22 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
nextPatch.allowOther = false;
nextPatch.otherLabel = undefined;
}
if (!canConvertValueToTag(next)) {
nextPatch.convertToTag = false;
}
return nextPatch;
}
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
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";
function reorderList<T>(items: T[], from: number, to: number): T[] {
@ -183,6 +194,8 @@ interface Question {
fixed?: boolean;
/** Optional note shown below the field on the public form. */
remarks?: string;
/** Registration: add submitted value(s) as participant tags. */
convertToTag?: boolean;
}
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]." />
</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}
{!isSection ? (
@ -452,14 +477,25 @@ interface FForm {
questions: Question[];
showThankYou: "Yes" | "No";
thankYouMsg: string;
formClosedMessage: string;
submitErrorMessage: string;
captchaFailedMessage: string;
registrationClosedMessage: string;
registrationDeadlineMessage: string;
registrationFullMessage: string;
duplicateEmailMessage: string;
duplicatePhoneMessage: string;
collectEmail: "Yes" | "No";
anonymous: "Yes" | "No";
publish: "Draft" | "Published";
paymentEnabled: "Yes" | "No";
amount: string;
confirmationEmail: "Yes" | "No";
confirmationEmailTemplateId: string;
onSubmitTags: string;
onSubmitListId: string;
allowDuplicateEmail: "Yes" | "No";
allowDuplicatePhone: "Yes" | "No";
headerShowLogo: "Yes" | "No";
headerShowTitle: "Yes" | "No";
headerShowDateRange: "Yes" | "No";
@ -468,6 +504,19 @@ interface FForm {
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 first = types[0];
return {
@ -487,15 +536,26 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
description: "",
questions: [newQ(qTypes)],
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",
anonymous: isFeedback ? "Yes" : "No",
publish: "Draft",
paymentEnabled: "No",
amount: "",
confirmationEmail: "Yes",
confirmationEmail: "No",
confirmationEmailTemplateId: "",
onSubmitTags: "",
onSubmitListId: "",
allowDuplicateEmail: "Yes",
allowDuplicatePhone: "Yes",
headerShowLogo: "Yes",
headerShowTitle: "Yes",
headerShowDateRange: "Yes",
@ -627,6 +687,28 @@ function toApiField(q: Question, mode: FormMode): FormField {
}
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"];
if (type === "select" || type === "radio" || type === "checkboxes") {
const base = { label, name, type, required, options: choiceOpts };
@ -648,6 +730,7 @@ function toApiFieldReg(q: Question): FormField {
mapsTo: q.mapsTo || undefined,
customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined,
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,
fixed: f.isFixed,
remarks: f.remarks ?? "",
convertToTag: f.convertToTag === true,
};
});
return assignUniqueFieldNames(questions);
@ -693,6 +777,7 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
customMapsKey: f.customMapsKey,
fixed: f.isFixed === true,
remarks: f.remarks ?? "",
convertToTag: f.convertToTag === true,
};
}
@ -730,7 +815,7 @@ export function CreateFormPanel({
const isOther = mode === "other";
const isWebCheckIn = mode === "web-check-in";
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 acc = useAccordion(TOTAL);
@ -741,6 +826,7 @@ export function CreateFormPanel({
return base;
});
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
const [emailerTemplates, setEmailerTemplates] = useState<DesignTemplate[]>([]);
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(!!formId);
@ -788,6 +874,27 @@ export function CreateFormPanel({
}).catch(() => setFixedLists([]));
}, [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(() => {
if (!formId) return;
let cancelled = false;
@ -808,15 +915,30 @@ export function CreateFormPanel({
description: data.description ?? "",
questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]),
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",
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
publish: s.isActive !== false ? "Published" : "Draft",
paymentEnabled: s.paymentEnabled ? "Yes" : "No",
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,
onSubmitListId: s.onSubmitParticipantListId ?? "",
allowDuplicateEmail: s.allowDuplicateEmail === false ? "No" : "Yes",
allowDuplicatePhone: s.allowDuplicatePhone === false ? "No" : "Yes",
headerShowLogo: header.showLogo,
headerShowTitle: header.showTitle,
headerShowDateRange: header.showDateRange,
@ -876,7 +998,11 @@ export function CreateFormPanel({
return anyLabel && choicesOk;
}
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;
return true;
}, [isReg, isWebCheckIn, selectedEventId]);
@ -910,6 +1036,13 @@ export function CreateFormPanel({
paymentEnabled: form.paymentEnabled === "Yes",
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
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({
showLogo: form.headerShowLogo,
showTitle: form.headerShowTitle,
@ -921,7 +1054,14 @@ export function CreateFormPanel({
...(isReg
? {
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();
onClose();
} catch {
setSectionErrors(prev => ({ ...prev, 1: "Failed to save. Please try again." }));
} catch (e) {
const msg = e instanceof Error && e.message.trim() ? e.message.trim() : "Failed to save. Please try again.";
setSectionErrors((prev) => ({ ...prev, 1: msg }));
} finally {
setSubmitting(false);
}
@ -1118,7 +1259,7 @@ export function CreateFormPanel({
<Field>
<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} />
<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>
<Label text="Add to fixed participant list" />
@ -1132,6 +1273,28 @@ export function CreateFormPanel({
</div>
<HelperText text="Only fixed (manual) lists in your organization are shown." />
</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>
)}
@ -1160,9 +1323,50 @@ export function CreateFormPanel({
)}
<Field>
<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" }]} />
<HelperText text="Send a confirmation email after registration." />
<RadioGroup
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>
{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>
</AccordionSection>
)}
@ -1170,7 +1374,7 @@ export function CreateFormPanel({
<AccordionSection
n={isReg ? 5 : 3}
title="Publish Settings"
subtitle="Set the form status and thank-you message"
subtitle="Status and respondent-facing messages"
open={acc.open === (isReg ? 5 : 3)}
completed={acc.done.has(isReg ? 5 : 3)}
onToggle={() => acc.toggle(isReg ? 5 : 3)}
@ -1188,12 +1392,129 @@ export function CreateFormPanel({
</select>
</div>
</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>
<Label text="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} />
<HelperText text="Shown to respondents after they submit the form." />
<Label text="Success / thank-you message" />
<textarea
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>
</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>
<Label text="Event header on public form" />
<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);
};
const hasActiveFilters = Boolean(q.trim() || dateFrom || dateTo || appliedQ || appliedFrom || appliedTo);
const clearFilters = () => {
setQ("");
setDateFrom("");
setDateTo("");
setAppliedQ("");
setAppliedFrom("");
setAppliedTo("");
setPage(1);
};
const onExport = async () => {
setExporting(true);
try {
@ -383,6 +395,25 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
>
Apply
</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
type="button"
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.
*/
import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches";
import type { BadgeLayoutElement } from "./badgeLayout";
import { defaultLayoutForFields } from "./badgeLayout";
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
import { defaultBackgroundImageLayout, defaultLayoutForFields, parseBackgroundImageLayout } from "./badgeLayout";
export type BadgeFormState = {
formName: string;
@ -19,6 +19,7 @@ export type BadgeFormState = {
backgroundMode: "image" | "color";
backgroundColor: string;
backgroundImageUrl: string;
backgroundImageLayout: BadgeBackgroundImageLayout;
enabledFields: string[];
layoutElements: BadgeLayoutElement[];
customTexts: { id: string; text: string }[];
@ -98,8 +99,12 @@ function customImagesFromConfig(c: Record<string, unknown>, layout: BadgeLayoutE
export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState {
const c = asConfig(batch);
const bgMode = str(c.backgroundMode, "color") === "image" ? "image" : "color";
const bgColor = str(c.backgroundColor, "#0B4DD1");
const 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"];
let layoutElements = layoutFromConfig(c);
if (!layoutElements.length && fields.length) {
@ -125,7 +130,8 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
participantListId: c.participantListId != null ? String(c.participantListId) : "",
backgroundMode: bgMode,
backgroundColor: bgColor,
backgroundImageUrl: str(c.backgroundImageUrl, ""),
backgroundImageUrl: bgImageUrl,
backgroundImageLayout,
enabledFields: fields,
layoutElements,
customTexts,

View File

@ -8,6 +8,59 @@ export type BadgeTextAlign = "left" | "center" | "right";
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 = {
id: string;
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);
}
/** 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 {
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 patch: Partial<BadgeLayoutElement> = { w: size };
if (isSquareResizable(el) || el.field === "photo") {
patch.h = size;
patch.h = squareElementHeightPct(size, badgeAspectRatio);
} else if (el.kind === "custom_image") {
const ar = el.imageAspectRatio ?? 1;
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
}
const merged = { ...el, ...patch };
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y) };
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) };
}
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
export function elementBoundsPct(el: BadgeLayoutElement): { w: number; h: number } {
if (el.field === "qr") {
export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } {
if (el.field === "qr" || el.field === "photo") {
const s = elementSizePct(el);
return { w: s, h: s };
return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) };
}
if (el.kind === "custom_image") {
return { w: el.w ?? 25, h: el.h ?? 25 };
}
if (el.field === "photo") {
const s = elementSizePct(el);
return { w: s, h: s };
}
if (isTextElement(el)) {
if (usesFullWidthCenter(el)) {
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
@ -141,8 +196,9 @@ export function clampElementPosition(
el: BadgeLayoutElement,
x: number,
y: number,
badgeAspectRatio = 1,
): { 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 maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
let cx = Math.min(maxX, Math.max(0, x));

View File

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

View File

@ -62,14 +62,16 @@ export function RadioGroup({
value,
onChange,
options,
disabled,
}: {
name: string;
value: string;
onChange: (v: string) => void;
options: { label: string; value: string }[];
disabled?: boolean;
}) {
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) => (
<label
key={o.value}
@ -77,7 +79,7 @@ export function RadioGroup({
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
cursor: disabled ? "not-allowed" : "pointer",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-noto-sans)",
color: "var(--card-foreground)",
@ -88,8 +90,9 @@ export function RadioGroup({
name={name}
value={o.value}
checked={value === o.value}
disabled={disabled}
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}
</label>

View File

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

View File

@ -18,11 +18,11 @@ export function ParticipantBulkActionBar({
selectedCount: number;
busy?: boolean;
variant: ParticipantBulkVariant;
onAddTags: () => void;
onRemoveTags: () => void;
onAddTags?: () => void;
onRemoveTags?: () => void;
onAssignEvents?: () => void;
onNewList: () => void;
onAddToList: () => void;
onNewList?: () => void;
onAddToList?: () => void;
onDeleteSelected?: () => void;
onRemoveFromEvent?: () => 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)" }}>
{selectedCount} selected
</span>
<button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button>
<button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button>
{onAddTags ? <button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button> : null}
{onRemoveTags ? <button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button> : null}
{variant === "portfolio" && onAssignEvents ? (
<button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button>
) : null}
<button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button>
<button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button>
{onNewList ? <button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button> : null}
{onAddToList ? <button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button> : null}
{variant === "portfolio" && onDeleteSelected ? (
<button
type="button"

View File

@ -14,6 +14,7 @@ export function ParticipantBulkDialogs({
onToggleTag,
availableTags,
removeTagOptions,
tagScope = "global",
newListName,
onNewListNameChange,
newListDesc,
@ -37,6 +38,8 @@ export function ParticipantBulkDialogs({
onToggleTag: (tag: string) => void;
availableTags: string[];
removeTagOptions: string[];
/** Event page = tags stored on this event membership; global = org-wide root tags. */
tagScope?: "event" | "global";
newListName: string;
onNewListNameChange: (v: string) => void;
newListDesc: string;
@ -99,12 +102,26 @@ export function ParticipantBulkDialogs({
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
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(
<>
<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
value={manualTagInput}
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 { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../contexts/PermissionContext";
import {
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
Phone, Mail, Calendar, User, Filter, UserCheck,
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
Users, UserX, ChevronLeft, ChevronRight, QrCode,
Users, ChevronLeft, ChevronRight, QrCode,
} from "lucide-react";
import { participantsApi } from "../../../services/api";
import type { Participant as ApiParticipant } from "../../../services/api/types";
@ -42,7 +43,7 @@ interface ParticipantDisplay {
function toDisplay(p: ApiParticipant): ParticipantDisplay {
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 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 {
id: p._id,
name,
@ -142,8 +143,18 @@ function CheckInBadge({ checked }: { checked: boolean }) {
}
function StatusDot({ status }: { status: string }) {
const color = status === "Cancelled" ? "var(--muted)" : status === "Checked In" ? "#15803d" : status === "Confirmed" ? "#2563eb" : "var(--card-foreground)";
const dotColor = status === "Cancelled" ? "#9ca3af" : status === "Checked In" ? "#16a34a" : status === "Confirmed" ? "#2563eb" : "#94a3b8";
const color =
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 (
<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" }} />
@ -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 ─────────────── */
function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
@ -184,16 +209,19 @@ interface RowProps {
onView: () => void;
onEdit: () => void;
onDelete: () => void;
onToggleCheckIn: () => void;
onConfirm: () => void;
onReject: () => void;
onQr: () => void;
canEdit?: boolean;
canDelete?: boolean;
selectable?: boolean;
selected?: boolean;
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 isPending = p.status === "Pending";
return (
<tr
@ -260,39 +288,46 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
{/* Actions — always visible */}
<td style={{ padding: "12px 16px 12px 8px", whiteSpace: "nowrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
{/* Quick check-in */}
<button
onClick={onToggleCheckIn}
title={p.checkedIn ? "Undo check-in" : "Mark checked in"}
style={{
display: "flex", alignItems: "center", gap: "4px",
padding: "4px 10px", borderRadius: "var(--radius-button)",
background: p.checkedIn ? "var(--secondary)" : "#dcfce7",
border: `1px solid ${p.checkedIn ? "var(--border)" : "#86efac"}`,
color: p.checkedIn ? "var(--muted)" : "#15803d",
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"}
</button>
{isPending && (
<>
<button
type="button"
onClick={onConfirm}
title="Confirm registration"
aria-label="Confirm registration"
style={{
display: "flex", alignItems: "center", gap: "4px",
padding: "4px 10px", 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"
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
type="button"
onClick={onQr}
title="Check-in QR code (EventBuzz Checkins app)"
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",
}}
aria-label="Check-in QR code"
style={iconBtnStyle}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "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)";
}}
>
<QrCode size={11} /> QR
<QrCode size={13} />
</button>
{/* View */}
<button onClick={onView} title="View profile"
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" }}
<button
type="button"
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)"; }}
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>
{canEdit && (
<button onClick={onEdit} title="Edit 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(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
<button
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)"; }}
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 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>
</td>
</tr>
@ -336,8 +384,9 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
/* ─────────────── 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 isPending = p.status === "Pending";
return (
<div
@ -371,22 +420,6 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
<StatusDot status={p.status} />
</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>
{/* Contact details */}
@ -407,13 +440,48 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
)}
</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 */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "10px", borderTop: "1px solid var(--border)" }}>
<CheckInBadge checked={p.checkedIn} />
<div style={{ display: "flex", gap: "4px" }}>
<button
type="button"
onClick={onQr}
title="Check-in QR code"
aria-label="Check-in QR code"
style={{
width: "26px",
height: "26px",
@ -435,26 +503,28 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
>
<QrCode size={12} />
</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)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
<Eye size={12} />
</button>
{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)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
<Pencil size={12} />
</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)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
<Trash2 size={12} />
</button>
{canDelete && (
<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)" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
>
<Trash2 size={12} />
</button>
)}
</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_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "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", "Rejected": "rejected" };
const DESIGNATION_OPTIONS = ["Delegate", "Speaker", "Moderator", "Panelist"];
/* ─────────────── Confirm Modal ─────────────── */
@ -553,10 +623,14 @@ export type ParticipantsPageProps = {
export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) {
const { selectedEventId, setActiveNav } = useAppNav();
const { user } = useAuth();
const { canModule } = usePermission();
const canWriteParticipants = canModule("participants", "write");
const canDeleteParticipants = canModule("participants", "delete");
const listOrgId = portfolioListOrganizationId(user);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
const isSuperAdmin = user?.role === "super_admin";
const canEditParticipants = canWriteParticipants || isSuperAdmin;
const enableBulk = !compactChrome;
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 checkedIn = list.filter(p => p.checkedIn).length;
const speakers = list.filter(p => ["Speaker", "Moderator", "Panelist"].includes(p.role)).length;
const deleteParticipant = (id: string) => {
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;
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 () => {
@ -810,27 +894,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
</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 ── */}
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{/* Top row: search + filter toggle + view toggle */}
@ -906,9 +969,13 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
onNewList={() => void bulk.openBulkDialog("newList")}
onAddToList={() => void bulk.openBulkDialog("addToList")}
onRemoveFromEvent={() => {
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
}}
onRemoveFromEvent={
canDeleteParticipants
? () => {
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
}
: undefined
}
onClearSelection={clearSelection}
/>
) : null}
@ -963,9 +1030,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onView={() => setViewModal(p)}
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
onDelete={() => deleteParticipant(p.id)}
onToggleCheckIn={() => toggleCheckIn(p.id)}
onConfirm={() => confirmRegistration(p.id)}
onReject={() => rejectRegistration(p.id)}
onQr={() => setQrParticipant(p)}
canEdit={isSuperAdmin}
canEdit={canEditParticipants}
canDelete={canDeleteParticipants}
/>
))}
</tbody>
@ -1001,9 +1070,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onView={() => setViewModal(p)}
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
onDelete={() => deleteParticipant(p.id)}
onToggleCheckIn={() => toggleCheckIn(p.id)}
onConfirm={() => confirmRegistration(p.id)}
onReject={() => rejectRegistration(p.id)}
onQr={() => setQrParticipant(p)}
canEdit={isSuperAdmin}
canEdit={canEditParticipants}
canDelete={canDeleteParticipants}
/>
))}
</div>
@ -1041,7 +1112,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
p={viewModal}
onClose={() => setViewModal(null)}
onEdit={() => { setEditId(viewModal.id); setViewModal(null); setShowAddPanel(true); }}
canEdit={isSuperAdmin}
canEdit={canEditParticipants}
/>
)}
{qrParticipant && (
@ -1097,6 +1168,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
onToggleTag={bulk.toggleSelectedTag}
availableTags={tagOptions}
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
tagScope={selectedEventId ? "event" : "global"}
newListName={bulk.newListName}
onNewListNameChange={bulk.setNewListName}
newListDesc={bulk.newListDesc}

View File

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

View File

@ -1,14 +1,18 @@
import React, { useState, useMemo } from "react";
import React, { useState, useMemo, useEffect } from "react";
import {
Search, X, ArrowRight, MapPin, Calendar, Users,
ChevronRight,
} from "lucide-react";
import {
doSearch, ALL_EVENTS, ALL_PAGES, ALL_ACTIONS,
doSearch, ALL_PAGES, ALL_ACTIONS,
STATUS_BADGE, LOC_ICON,
mapApiEventToSearchEvent,
SearchEvent, SearchPage, SearchAction,
} from "./searchIndex";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext";
import { eventsApi } from "../../../services/api";
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
/* ─────────────────────────────────────────
Props
@ -230,8 +234,36 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
const [activeTab, setActiveTab] = useState<FilterTab>("all");
const bp = useBreakpoint();
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();
/* Filter tab data */
@ -312,9 +344,14 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
Recent Events
</p>
<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} />
))}
{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>

View File

@ -44,21 +44,58 @@ export const LOC_ICON: Record<LocationType, React.ReactNode> = {
Hybrid: <Zap size={10} />,
};
/* ─── Events ─── */
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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
{ 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 },
];
/* ─── Events (populated at runtime from current-org API; no cross-org demo data) ─── */
export const ALL_EVENTS: SearchEvent[] = [];
function formatShortDate(iso: string | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
}
function toEventStatus(raw: string | undefined): EventStatus {
const k = String(raw || "draft").toLowerCase();
if (k === "live") return "Live";
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 ─── */
export const ALL_PAGES: SearchPage[] = [
@ -113,13 +150,13 @@ export const SUGGESTED_SHORTCUTS: { label: string; navId: string; icon: React.Re
];
/* ─── Search function ─── */
export function doSearch(query: string): SearchResults {
export function doSearch(query: string, orgEvents: SearchEvent[] = ALL_EVENTS): SearchResults {
const q = query.trim().toLowerCase();
if (!q) return { events: [], pages: [], actions: [], total: 0 };
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));
const pages = ALL_PAGES.filter(p =>
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;
}
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 {
value: string;
onChange: (value: string) => void;

View File

@ -12,11 +12,16 @@ interface PermissionContextValue {
loading: boolean;
}
const ACTION_IMPLIES: Record<AclAction, AclAction[]> = {
read: ["read"],
write: ["read", "write"],
delete: ["read", "write", "delete"],
};
/** Higher privileges grant lower ones: delete ⇒ write ⇒ read (matches backend actionsInclude). */
function actionsInclude(entryActions: string[], action: AclAction): boolean {
if (!Array.isArray(entryActions)) return false;
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);
@ -29,7 +34,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
if (!permissions) return user?.role === "super_admin";
if (permissions.isOrgSuperAdmin) return true;
const actions = permissions.modules?.[moduleKey] || [];
return (ACTION_IMPLIES[action] || [action]).some((a) => actions.includes(a));
return actionsInclude(actions, action);
},
[permissions, user?.role],
);
@ -72,6 +77,8 @@ export const NAV_MODULE_MAP: Record<string, string> = {
activities: "live_polls",
faculty: "speaker_management",
"all-participants": "participants",
"participant-lists": "participants",
attendance: "attendance",
forms: "forms",
"forms-feedback": "forms",
"forms-registration": "forms",
@ -85,7 +92,6 @@ export const NAV_MODULE_MAP: Record<string, string> = {
payment: "billing",
"user-management": "user_management",
"roles-management": "user_management",
"participant-lists": "participants",
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
settings: "settings",
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() {
const [rows, setRows] = useState<OrganizationRecord[]>([]);
const [total, setTotal] = useState(0);
@ -220,7 +226,7 @@ export function SuperAdminClientsPage() {
<input
value={searchInput}
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" }}
/>
<button
@ -252,7 +258,8 @@ export function SuperAdminClientsPage() {
<thead>
<tr style={{ background: "var(--accent)", textAlign: "left" }}>
<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 }}>Status</th>
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Users</th>
@ -263,40 +270,63 @@ export function SuperAdminClientsPage() {
<tbody>
{rows.length === 0 ? (
<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
? "No organizations match your search."
: "No organizations yet. Use “Add organization” to create a tenant and its first super admin user."}
</td>
</tr>
) : (
rows.map((org) => (
<tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}>
<td style={{ padding: "10px 12px", color: "var(--foreground)" }}>{org.name}</td>
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</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>
))
rows.map((org) => {
const admin = org.primaryAdmin;
const adminName = formatAdminName(admin);
return (
<tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}>
<td style={{ padding: "10px 12px", color: "var(--foreground)" }}>{org.name}</td>
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</td>
<td style={{ padding: "10px 12px", verticalAlign: "top" }}>
{admin ? (
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
<span style={{ color: "var(--foreground)", fontWeight: 500 }}>
{adminName || "—"}
</span>
<span style={{ color: "var(--muted)", fontSize: "var(--text-xs)" }}>
{admin.email || "—"}
</span>
{admin.phone ? (
<span style={{ color: "var(--muted)", fontSize: "var(--text-xs)" }}>
{admin.phone}
</span>
) : null}
</div>
) : (
<span style={{ color: "var(--muted)" }}>—</span>
)}
</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>
</table>

View File

@ -18,6 +18,7 @@ export const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
pending: { bg: '#fef3c7', color: '#b45309' },
confirmed: { bg: '#dcfce7', color: '#16a34a' },
checked_in: { bg: '#dbeafe', color: '#2563eb' },
rejected: { bg: '#fee2e2', color: '#dc2626' },
};
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 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 const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'] as const;

View File

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

View File

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

View File

@ -45,7 +45,7 @@ export { formsApi } from './forms';
export { rolesApi } from './roles';
export { usersApi } from './users';
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 { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
export {
@ -79,6 +79,7 @@ export {
type MessagingCampaign,
type CampaignStatus,
type CampaignContent,
type CampaignRecipients,
type WhatsAppQueueJobSummary,
} from './campaigns';
export { subscriptionApi, loadRazorpayScript } from './subscription';

View File

@ -27,6 +27,8 @@ interface ListAllParams {
limit?: number;
q?: string;
tag?: string;
/** When true, list soft-deleted participants only. */
deleted?: boolean;
}
export const participantsApi = {
@ -37,6 +39,7 @@ export const participantsApi = {
sp.set('limit', String(params?.limit ?? 20));
if (params?.q?.trim()) sp.set('q', params.q.trim());
if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
if (params?.deleted) sp.set('deleted', 'true');
const query = sp.toString();
return api.get<{ participants: ParticipantWithEvent[]; total: number; page: number; limit: number }>(
`/participants${query ? `?${query}` : ''}`
@ -127,23 +130,39 @@ export const participantsApi = {
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) {
const q = new URLSearchParams({ event_id: eventId });
return api.delete<unknown>(`/participants/${id}?${q.toString()}`);
},
/** Remove participant record entirely (all events). */
/** Soft-delete participant record (all events). */
deleteOne(id: string) {
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) {
const q = new URLSearchParams({ event_id: eventId });
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. */
async downloadExport(eventId: string, fileName = 'participants-export.csv') {
async downloadExport(eventId: string, fileName?: string) {
const res = await fetchWithAuth(`/events/${eventId}/participants/export`, { method: 'GET' });
if (!res.ok) {
let msg = res.statusText;
@ -155,11 +174,12 @@ export const participantsApi = {
}
return { ok: false as const, error: msg };
}
const fromHeader = parseContentDispositionFilename(res.headers.get('Content-Disposition'));
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.download = fileName || fromHeader || 'Event - Participants.csv';
a.click();
URL.revokeObjectURL(url);
return { ok: true as const };
@ -224,3 +244,20 @@ export const participantsApi = {
}>('/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;
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
enableQnP?: boolean;
/** Mongo id of portfolio feedback form, or omit/null for default attendee UI */
audienceFeedbackForm?: string | null;
audienceRatingsForm?: string | null;
/** Nested session topics for hierarchical programmes */
topics?: SessionTopic[];
}
export interface SessionTopic {
type?: string;
duration?: string;
title?: string;
role?: string;
}
export type SessionPayload = {
@ -59,6 +65,7 @@ export type SessionPayload = {
audienceRatingsForm?: string | null;
enableQnA?: boolean;
enableQnP?: boolean;
topics?: SessionTopic[];
};
export const sessionsApi = {

View File

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

View File

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

View File

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