22 july
This commit is contained in:
parent
bad7d38777
commit
3feb2ff20a
|
|
@ -12,8 +12,8 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<script type="module" crossorigin src="/assets/index-DJFhkFks.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dj8crrlq.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-1SgcqEoJ.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ export function AppShell() {
|
||||||
}
|
}
|
||||||
}, [selectedEventId, activeNav, setActiveNav]);
|
}, [selectedEventId, activeNav, setActiveNav]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeNav === "dashboard" && selectedEventId) {
|
||||||
|
setSidebarCollapsed(true);
|
||||||
|
}
|
||||||
|
}, [activeNav, selectedEventId, setSidebarCollapsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useAppNav } from "../contexts/AppNavContext";
|
import { useAppNav } from "../contexts/AppNavContext";
|
||||||
import { useBreakpoint } from "../hooks/useBreakpoint";
|
import { useBreakpoint } from "../hooks/useBreakpoint";
|
||||||
import { CreateEventPanel } from "./panels/CreateEventPanel";
|
import { CreateEventPanel } from "./panels/CreateEventPanel";
|
||||||
|
import { WebsiteThemeSettingsPanel } from "./panels/WebsiteThemeSettingsPanel";
|
||||||
import { EventHomeHeaderActions } from "./dashboard/EventHomeHeaderActions";
|
import { EventHomeHeaderActions } from "./dashboard/EventHomeHeaderActions";
|
||||||
import { EventHomeModules } from "./dashboard/EventHomeModules";
|
import { EventHomeModules } from "./dashboard/EventHomeModules";
|
||||||
import { EventAnalyticsPage } from "./analytics/EventAnalyticsPage";
|
import { EventAnalyticsPage } from "./analytics/EventAnalyticsPage";
|
||||||
|
|
@ -49,6 +50,7 @@ export function MainContent() {
|
||||||
setSettingsInitialSection,
|
setSettingsInitialSection,
|
||||||
} = useAppNav();
|
} = useAppNav();
|
||||||
const [editEventPanelOpen, setEditEventPanelOpen] = useState(false);
|
const [editEventPanelOpen, setEditEventPanelOpen] = useState(false);
|
||||||
|
const [websiteThemePanelOpen, setWebsiteThemePanelOpen] = useState(false);
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const [dashEvent, setDashEvent] = useState<Event | null>(null);
|
const [dashEvent, setDashEvent] = useState<Event | null>(null);
|
||||||
const [dashLoading, setDashLoading] = useState(false);
|
const [dashLoading, setDashLoading] = useState(false);
|
||||||
|
|
@ -197,6 +199,7 @@ export function MainContent() {
|
||||||
loading={dashLoading && !!selectedEventId}
|
loading={dashLoading && !!selectedEventId}
|
||||||
canEdit={!!selectedEventId}
|
canEdit={!!selectedEventId}
|
||||||
onEditEvent={() => selectedEventId && setEditEventPanelOpen(true)}
|
onEditEvent={() => selectedEventId && setEditEventPanelOpen(true)}
|
||||||
|
onWebsiteSettings={() => selectedEventId && setWebsiteThemePanelOpen(true)}
|
||||||
/>
|
/>
|
||||||
{editEventPanelOpen && selectedEventId && (
|
{editEventPanelOpen && selectedEventId && (
|
||||||
<CreateEventPanel
|
<CreateEventPanel
|
||||||
|
|
@ -208,6 +211,15 @@ export function MainContent() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{websiteThemePanelOpen && selectedEventId && (
|
||||||
|
<WebsiteThemeSettingsPanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
onClose={() => setWebsiteThemePanelOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
void loadDashboardData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<EventHomeModules />
|
<EventHomeModules />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const EVENT_TAB_MODULES: Record<string, string> = {
|
||||||
dashboard: "events",
|
dashboard: "events",
|
||||||
agenda: "sessions",
|
agenda: "sessions",
|
||||||
participants: "participants",
|
participants: "participants",
|
||||||
attendance: "participants",
|
attendance: "attendance",
|
||||||
"event-activities": "live_polls",
|
"event-activities": "live_polls",
|
||||||
rewards: "gamification",
|
rewards: "gamification",
|
||||||
analytics: "events",
|
analytics: "events",
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ const PORTFOLIO_NAV: NavItem[] = [
|
||||||
id: "design", label: "Design", icon: <Palette size={16} />,
|
id: "design", label: "Design", icon: <Palette size={16} />,
|
||||||
children: [
|
children: [
|
||||||
{ id: "design-brochure", label: "Brochure" },
|
{ id: "design-brochure", label: "Brochure" },
|
||||||
{ id: "design-microsite", label: "Microsite Templates" },
|
|
||||||
{ id: "design-badges", label: "Badges" },
|
{ id: "design-badges", label: "Badges" },
|
||||||
{ id: "design-emailers", label: "Emailers" },
|
{ id: "design-emailers", label: "Emailers" },
|
||||||
{ id: "design-whatsapp-templates", label: "WhatsApp Templates" },
|
{ id: "design-whatsapp-templates", label: "WhatsApp Templates" },
|
||||||
|
|
@ -459,28 +458,154 @@ function BottomUser({ collapsed, onNavigate }: { collapsed: boolean; onNavigate:
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<Tooltip label={`${displayName} · Log out`}>
|
<>
|
||||||
<button type="button" onClick={handleLogout} style={{ ...btnStyle, padding: "6px 0" }}
|
<Tooltip label={`${displayName} · Settings`}>
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--accent)"; }}
|
<button
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "transparent"; }}>
|
type="button"
|
||||||
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
onClick={() => onNavigate("settings")}
|
||||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span>
|
style={{ ...btnStyle, padding: "6px 0" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--accent)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--primary)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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)" }}>
|
<div
|
||||||
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
style={{
|
||||||
<span style={{ color: "#fff", fontSize: "10px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{initials}</span>
|
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>
|
||||||
<div style={{ flex: 1, overflow: "hidden" }}>
|
<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
|
||||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", margin: 0 }}>{email || "—"}</p>
|
data-user-name
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
fontWeight: "var(--font-weight-medium)",
|
||||||
|
color: "var(--sidebar-foreground)",
|
||||||
|
lineHeight: "1.3",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "10px",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{email || "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={handleLogout} title="Log out"
|
</button>
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "2px", flexShrink: 0 }}
|
<button
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
|
type="button"
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
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} />
|
<LogOut size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
import React, { useState, useRef, useEffect, useMemo } from "react";
|
import React, { useState, useRef, useEffect, useMemo } from "react";
|
||||||
import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard } from "lucide-react";
|
import { Bell, Search, Menu, X, ArrowRight, MapPin, Calendar, ChevronRight, CreditCard, ChevronDown } from "lucide-react";
|
||||||
import { useBreakpoint } from "../hooks/useBreakpoint";
|
import { useBreakpoint } from "../hooks/useBreakpoint";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useAppNav } from "../contexts/AppNavContext";
|
import { useAppNav } from "../contexts/AppNavContext";
|
||||||
import { organizationApi, subscriptionApi } from "../../services/api";
|
import { organizationApi, subscriptionApi, eventsApi } from "../../services/api";
|
||||||
import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types";
|
import type { OrganizationRecord, SubscriptionPlanCatalog } from "../../services/api/types";
|
||||||
|
import { portfolioListOrganizationId } from "../../services/api/scopedOrganization";
|
||||||
import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents";
|
import { ORGANIZATION_SUBSCRIPTION_UPDATED } from "../constants/appEvents";
|
||||||
import {
|
import {
|
||||||
doSearch, SUGGESTED_SHORTCUTS,
|
doSearch,
|
||||||
STATUS_BADGE, LOC_ICON,
|
STATUS_BADGE,
|
||||||
|
mapApiEventToSearchEvent,
|
||||||
SearchEvent, SearchPage, SearchAction, SearchResults,
|
SearchEvent, SearchPage, SearchAction, SearchResults,
|
||||||
ALL_EVENTS,
|
|
||||||
} from "./search/searchIndex";
|
} from "./search/searchIndex";
|
||||||
|
|
||||||
/* ─────────────────────────────────────────
|
/* ─────────────────────────────────────────
|
||||||
|
|
@ -76,72 +77,9 @@ function SearchDropdown({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Suggested shortcuts (empty query) ── */
|
/* ── Empty query: no menu (avoid static Suggested / Recent Events panel) ── */
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return (
|
return null;
|
||||||
<div>
|
|
||||||
{/* Suggested chips */}
|
|
||||||
<div style={{ padding: "12px 14px 8px" }}>
|
|
||||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: "8px" }}>
|
|
||||||
Suggested
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
|
|
||||||
{SUGGESTED_SHORTCUTS.map(s => (
|
|
||||||
<button
|
|
||||||
key={s.navId}
|
|
||||||
onClick={() => onPageClick({ id: s.navId, navId: s.navId, title: s.label, section: "", description: "", icon: s.icon })}
|
|
||||||
style={{
|
|
||||||
display: "inline-flex", alignItems: "center", gap: "5px",
|
|
||||||
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
|
||||||
border: "1px solid var(--border)", background: "var(--secondary)",
|
|
||||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
|
||||||
color: "var(--card-foreground)", cursor: "pointer",
|
|
||||||
transition: "background 0.1s, border-color 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--primary)" }}>{s.icon}</span>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div style={{ borderTop: "1px solid var(--border)", margin: "4px 0" }} />
|
|
||||||
|
|
||||||
{/* Recent events */}
|
|
||||||
<div style={{ padding: "4px 14px 10px" }}>
|
|
||||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "700", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: "4px", paddingTop: "8px" }}>
|
|
||||||
Recent Events
|
|
||||||
</p>
|
|
||||||
{ALL_EVENTS.slice(0, 3).map(e => {
|
|
||||||
const badge = STATUS_BADGE[e.status];
|
|
||||||
return (
|
|
||||||
<button key={e.id} onClick={() => onEventClick(e)}
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: "10px",
|
|
||||||
width: "100%", padding: "8px 0", textAlign: "left",
|
|
||||||
background: "none", border: "none", borderBottom: "1px solid var(--border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e2 => { (e2.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e2.currentTarget as HTMLElement).style.borderRadius = "var(--radius-button)"; (e2.currentTarget as HTMLElement).style.paddingLeft = "6px"; }}
|
|
||||||
onMouseLeave={e2 => { (e2.currentTarget as HTMLElement).style.background = "none"; (e2.currentTarget as HTMLElement).style.paddingLeft = "0"; }}
|
|
||||||
>
|
|
||||||
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: badge.dot ?? badge.color, flexShrink: 0 }} />
|
|
||||||
<span style={{ flex: 1, fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
{e.title}
|
|
||||||
</span>
|
|
||||||
<span style={{ padding: "1px 6px", borderRadius: "999px", background: badge.bg, color: badge.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0 }}>
|
|
||||||
{e.status}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── No results ── */
|
/* ── No results ── */
|
||||||
|
|
@ -363,12 +301,138 @@ function formatRenewalSuffix(expiresAt: string | undefined): { desktop: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationsBell() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: open ? "var(--secondary)" : "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: open ? "var(--foreground)" : "var(--muted)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
}}
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "var(--secondary)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--foreground)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (open) return;
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "none";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bell size={16} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Notifications"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "36px",
|
||||||
|
right: 0,
|
||||||
|
width: "min(320px, calc(100vw - 24px))",
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
boxShadow: "var(--elevation-sm)",
|
||||||
|
zIndex: 250,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "12px 14px",
|
||||||
|
background: "var(--secondary)",
|
||||||
|
border: "none",
|
||||||
|
borderBottom: expanded ? "1px solid var(--border)" : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
fontWeight: "var(--font-weight-medium)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Notifications
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
color: "var(--muted)",
|
||||||
|
transform: expanded ? "rotate(180deg)" : "none",
|
||||||
|
transition: "transform 0.15s",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ padding: "16px 14px" }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No new notifications
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LoggedInUserButton({ isMobile }: { isMobile: boolean }) {
|
function LoggedInUserButton({ isMobile }: { isMobile: boolean }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { setActiveNav } = useAppNav();
|
||||||
const name = userDisplayName(user ?? null);
|
const name = userDisplayName(user ?? null);
|
||||||
const initials = userInitials(user ?? null);
|
const initials = userInitials(user ?? null);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Open settings for ${name}`}
|
||||||
|
title="Settings"
|
||||||
|
onClick={() => setActiveNav("settings")}
|
||||||
style={{ display: "flex", alignItems: "center", gap: isMobile ? "0" : "8px", padding: isMobile ? "4px" : "4px 8px 4px 4px", borderRadius: "var(--radius-button)", background: "none", border: "none", cursor: "pointer" }}
|
style={{ display: "flex", alignItems: "center", gap: isMobile ? "0" : "8px", padding: isMobile ? "4px" : "4px 8px 4px 4px", borderRadius: "var(--radius-button)", background: "none", border: "none", cursor: "pointer" }}
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; }}
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; }}
|
||||||
|
|
@ -398,19 +462,47 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
const [planCatalog, setPlanCatalog] = useState<SubscriptionPlanCatalog[]>([]);
|
const [planCatalog, setPlanCatalog] = useState<SubscriptionPlanCatalog[]>([]);
|
||||||
/** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */
|
/** Bumps when subscription/checkout updates org server-side so we refetch without relying on user._id alone. */
|
||||||
const [orgDataVersion, setOrgDataVersion] = useState(0);
|
const [orgDataVersion, setOrgDataVersion] = useState(0);
|
||||||
|
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
|
||||||
|
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
const isTablet = bp === "tablet";
|
const isTablet = bp === "tablet";
|
||||||
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
|
|
||||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const desktopInputRef = useRef<HTMLInputElement>(null);
|
const desktopInputRef = useRef<HTMLInputElement>(null);
|
||||||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
/* ── Current-org events for search (never cross-tenant demo data) ── */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listOrgId) {
|
||||||
|
setOrgSearchEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const res = await eventsApi.list({
|
||||||
|
organization: listOrgId,
|
||||||
|
recordKind: "event",
|
||||||
|
page: 1,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.error || !res.data?.events) {
|
||||||
|
setOrgSearchEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [listOrgId]);
|
||||||
|
|
||||||
/* ── Search results ── */
|
/* ── Search results ── */
|
||||||
const results: SearchResults | null = useMemo(
|
const results: SearchResults | null = useMemo(
|
||||||
() => query.trim() ? doSearch(query) : null,
|
() => (query.trim() ? doSearch(query, orgSearchEvents) : null),
|
||||||
[query],
|
[query, orgSearchEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Flat items for keyboard nav ── */
|
/* ── Flat items for keyboard nav ── */
|
||||||
|
|
@ -498,13 +590,13 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
setTimeout(() => mobileInputRef.current?.focus(), 50);
|
setTimeout(() => mobileInputRef.current?.focus(), 50);
|
||||||
} else {
|
} else {
|
||||||
desktopInputRef.current?.focus();
|
desktopInputRef.current?.focus();
|
||||||
setDropdownOpen(true);
|
if (query.trim()) setDropdownOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handler);
|
document.addEventListener("keydown", handler);
|
||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [isMobile]);
|
}, [isMobile, query]);
|
||||||
|
|
||||||
/* ── Keyboard navigation inside input ── */
|
/* ── Keyboard navigation inside input ── */
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -617,14 +709,14 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Search events, pages, actions…"
|
placeholder="Search events, pages, actions…"
|
||||||
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(true); }}
|
onChange={e => { setQuery(e.target.value); setActiveIndex(-1); setDropdownOpen(!!e.target.value.trim()); }}
|
||||||
onFocus={() => { setFocused(true); setDropdownOpen(true); }}
|
onFocus={() => { setFocused(true); if (query.trim()) setDropdownOpen(true); }}
|
||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
|
style={{ background: "transparent", border: "none", outline: "none", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)", width: "100%" }}
|
||||||
/>
|
/>
|
||||||
{query ? (
|
{query ? (
|
||||||
<button onClick={() => { setQuery(""); setActiveIndex(-1); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}>
|
<button onClick={() => { setQuery(""); setActiveIndex(-1); setDropdownOpen(false); }} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", display: "flex", padding: "0", flexShrink: 0 }}>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -634,8 +726,8 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Dropdown ── */}
|
{/* ── Dropdown (only when there is a query) ── */}
|
||||||
{dropdownOpen && (
|
{dropdownOpen && query.trim() && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute", top: "32px", left: 0,
|
position: "absolute", top: "32px", left: 0,
|
||||||
|
|
@ -729,16 +821,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bell */}
|
<NotificationsBell />
|
||||||
<button
|
|
||||||
style={{ position: "relative", width: "32px", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", background: "none", border: "none", cursor: "pointer", color: "var(--muted)", borderRadius: "var(--radius-button)" }}
|
|
||||||
aria-label="Notifications (3 unread)"
|
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = "var(--secondary)"; (e.currentTarget as HTMLElement).style.color = "var(--foreground)"; }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = "none"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
|
||||||
>
|
|
||||||
<Bell size={16} />
|
|
||||||
<span style={{ position: "absolute", top: "4px", right: "4px", minWidth: "14px", height: "14px", borderRadius: "999px", background: "var(--destructive)", border: "2px solid var(--card)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "8px", fontFamily: "var(--font-inter)", fontWeight: "700", color: "var(--destructive-foreground)", padding: "0 2px", lineHeight: 1 }}>3</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} />
|
<div style={{ width: "1px", height: "20px", background: "var(--border)", margin: "0 2px" }} />
|
||||||
|
|
||||||
|
|
@ -786,7 +869,8 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropdown results */}
|
{/* Dropdown results — only when typing */}
|
||||||
|
{query.trim() ? (
|
||||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||||
<SearchDropdown
|
<SearchDropdown
|
||||||
query={query}
|
query={query}
|
||||||
|
|
@ -798,6 +882,7 @@ export function TopBar({ onMenuClick, onNavigate, onEventSelect, onOpenSearchPag
|
||||||
onViewAll={handleViewAll}
|
onViewAll={handleViewAll}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Search, X, LayoutGrid, List, Calendar as CalIcon,
|
Search, X, LayoutGrid, List, Calendar as CalIcon,
|
||||||
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2,
|
MessageCircle, BarChart3, HelpCircle, Users, CheckCircle2, Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
} from "../quiz/AddQuizPollModal";
|
} from "../quiz/AddQuizPollModal";
|
||||||
import type { Question } from "../quiz/AddQuestionModal";
|
import type { Question } from "../quiz/AddQuestionModal";
|
||||||
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
||||||
|
import { formatActivityRating, weightedActivityRating } from "./activityRatingDisplay";
|
||||||
|
|
||||||
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
||||||
return modalQuestionToApi(q, kind);
|
return modalQuestionToApi(q, kind);
|
||||||
|
|
@ -41,6 +42,10 @@ interface ActivityRow {
|
||||||
participants: number;
|
participants: number;
|
||||||
questions: number;
|
questions: number;
|
||||||
responses: number;
|
responses: number;
|
||||||
|
eventRatingAverage: number;
|
||||||
|
eventRatingCount: number;
|
||||||
|
sessionRatingAverage: number;
|
||||||
|
sessionRatingCount: number;
|
||||||
subtype: "qna" | "quiz" | "poll";
|
subtype: "qna" | "quiz" | "poll";
|
||||||
raw: Event;
|
raw: Event;
|
||||||
}
|
}
|
||||||
|
|
@ -131,6 +136,10 @@ function eventToRow(ev: Event): ActivityRow {
|
||||||
participants: st?.participantCount ?? 0,
|
participants: st?.participantCount ?? 0,
|
||||||
questions: st?.questionCount ?? 0,
|
questions: st?.questionCount ?? 0,
|
||||||
responses: st?.responseCount ?? 0,
|
responses: st?.responseCount ?? 0,
|
||||||
|
eventRatingAverage: st?.eventRatingAverage ?? 0,
|
||||||
|
eventRatingCount: st?.eventRatingCount ?? 0,
|
||||||
|
sessionRatingAverage: st?.sessionRatingAverage ?? 0,
|
||||||
|
sessionRatingCount: st?.sessionRatingCount ?? 0,
|
||||||
subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna",
|
subtype: (ev.activitySubtype as "qna" | "quiz" | "poll") || "qna",
|
||||||
raw: ev,
|
raw: ev,
|
||||||
};
|
};
|
||||||
|
|
@ -391,11 +400,19 @@ export function ActivitiesPage({
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const base = rows;
|
const base = rows;
|
||||||
|
const eventRating = weightedActivityRating(
|
||||||
|
base.map((a) => ({ average: a.eventRatingAverage, count: a.eventRatingCount })),
|
||||||
|
);
|
||||||
|
const sessionRating = weightedActivityRating(
|
||||||
|
base.map((a) => ({ average: a.sessionRatingAverage, count: a.sessionRatingCount })),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
count: base.length,
|
count: base.length,
|
||||||
active: base.filter(a => a.status === "Active").length,
|
active: base.filter(a => a.status === "Active").length,
|
||||||
participants: base.reduce((s, a) => s + a.participants, 0),
|
participants: base.reduce((s, a) => s + a.participants, 0),
|
||||||
responses: base.reduce((s, a) => s + a.responses, 0),
|
responses: base.reduce((s, a) => s + a.responses, 0),
|
||||||
|
eventRating,
|
||||||
|
sessionRating,
|
||||||
};
|
};
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
|
|
||||||
|
|
@ -474,11 +491,23 @@ export function ActivitiesPage({
|
||||||
|
|
||||||
{!loading && orgId ? (
|
{!loading && orgId ? (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(4, 1fr)", gap: "12px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(3, 1fr)", gap: "12px" }}>
|
||||||
<StatCard label="Total Activities" value={String(totals.count)} />
|
<StatCard label="Total Activities" value={String(totals.count)} />
|
||||||
<StatCard label="Active" value={String(totals.active)} color="#15803d" />
|
<StatCard label="Active" value={String(totals.active)} color="#15803d" />
|
||||||
<StatCard label="Total Participants" value={String(totals.participants)} />
|
<StatCard label="Total Participants" value={String(totals.participants)} />
|
||||||
<StatCard label="Total Responses" value={String(totals.responses)} />
|
<StatCard label="Total Responses" value={String(totals.responses)} />
|
||||||
|
<StatCard
|
||||||
|
label="Event Rating"
|
||||||
|
value={formatActivityRating(totals.eventRating.average, totals.eventRating.count)}
|
||||||
|
sublabel={totals.eventRating.count ? `${totals.eventRating.count} ratings` : "No ratings yet"}
|
||||||
|
color="#d97706"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Session Rating"
|
||||||
|
value={formatActivityRating(totals.sessionRating.average, totals.sessionRating.count)}
|
||||||
|
sublabel={totals.sessionRating.count ? `${totals.sessionRating.count} ratings` : "No ratings yet"}
|
||||||
|
color="#d97706"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
|
@ -704,7 +733,7 @@ export function ActivitiesPage({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, color }: { label: string; value: string; color?: string }) {
|
function StatCard({ label, value, color, sublabel }: { label: string; value: string; color?: string; sublabel?: string }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "16px 20px" }}>
|
<div style={{ background: "var(--card)", borderRadius: "var(--radius-card)", borderWidth: "1px", borderStyle: "solid", borderColor: "var(--border)", padding: "16px 20px" }}>
|
||||||
<div style={{ fontSize: "var(--text-2xl)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: color || "var(--foreground)", marginBottom: "4px" }}>
|
<div style={{ fontSize: "var(--text-2xl)", fontFamily: "var(--font-inter)", fontWeight: "var(--font-weight-medium)", color: color || "var(--foreground)", marginBottom: "4px" }}>
|
||||||
|
|
@ -713,6 +742,11 @@ function StatCard({ label, value, color }: { label: string; value: string; color
|
||||||
<div style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
<div style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.5px" }}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
{sublabel ? (
|
||||||
|
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "6px" }}>
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -753,7 +787,7 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}>
|
<div style={{ display: "flex", gap: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)", flexWrap: "wrap" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
<Users size={14} style={{ color: "var(--muted)" }} />
|
<Users size={14} style={{ color: "var(--muted)" }} />
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.participants}</span>
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.participants}</span>
|
||||||
|
|
@ -766,6 +800,18 @@ function ActivityCard({ activity, onClick }: { activity: ActivityRow; onClick: (
|
||||||
<CheckCircle2 size={14} style={{ color: "var(--muted)" }} />
|
<CheckCircle2 size={14} style={{ color: "var(--muted)" }} />
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.responses}</span>
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>{activity.responses}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Event rating">
|
||||||
|
<Star size={14} style={{ color: "#d97706" }} />
|
||||||
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||||
|
{formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }} title="Session rating">
|
||||||
|
<Star size={14} style={{ color: "#d97706", opacity: 0.75 }} />
|
||||||
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||||
|
{formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -802,10 +848,16 @@ function ActivityListRow({ activity, isLast, onClick }: { activity: ActivityRow;
|
||||||
{activity.type}
|
{activity.type}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "12px", minWidth: "120px" }}>
|
<div style={{ display: "flex", gap: "12px", minWidth: "120px", flexWrap: "wrap" }}>
|
||||||
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||||
{activity.participants} participants
|
{activity.participants} participants
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||||
|
Event {formatActivityRating(activity.eventRatingAverage, activity.eventRatingCount)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--card-foreground)" }}>
|
||||||
|
Session {formatActivityRating(activity.sessionRatingAverage, activity.sessionRatingCount)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase" }}>
|
<span style={{ padding: "3px 10px", borderRadius: "999px", background: statusConfig.bg, color: statusConfig.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", flexShrink: 0, textTransform: "uppercase" }}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Activity detail — single-session activity shell with tabs (Questions, Quiz & Polls, Participants, Settings).
|
* Activity detail — single-session activity shell with tabs (Questions, Quiz & Polls, Participants, Settings).
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { QrCode } from "lucide-react";
|
import { QrCode, Users, HelpCircle, CheckCircle2, Star } from "lucide-react";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { eventsApi } from "../../../services/api/events";
|
import { eventsApi } from "../../../services/api/events";
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
} from "../quiz/AddQuizPollModal";
|
} from "../quiz/AddQuizPollModal";
|
||||||
import type { Question } from "../quiz/AddQuestionModal";
|
import type { Question } from "../quiz/AddQuestionModal";
|
||||||
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
import { modalQuestionToApi } from "../quiz/quizQuestionMappers";
|
||||||
|
import { formatActivityRating } from "./activityRatingDisplay";
|
||||||
|
|
||||||
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
function mapQuestionForQuizPollApi(q: Question, kind: "quiz" | "poll") {
|
||||||
return modalQuestionToApi(q, kind);
|
return modalQuestionToApi(q, kind);
|
||||||
|
|
@ -61,7 +62,7 @@ export function ActivityDetailPage() {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const [evRes, sesRes] = await Promise.all([
|
const [evRes, sesRes] = await Promise.all([
|
||||||
eventsApi.get(selectedEventId),
|
eventsApi.get(selectedEventId, { enrich: true }),
|
||||||
sessionsApi.list(selectedEventId),
|
sessionsApi.list(selectedEventId),
|
||||||
]);
|
]);
|
||||||
if (evRes.error || !evRes.data) {
|
if (evRes.error || !evRes.data) {
|
||||||
|
|
@ -105,6 +106,7 @@ export function ActivityDetailPage() {
|
||||||
}, [tabs, activeTab]);
|
}, [tabs, activeTab]);
|
||||||
|
|
||||||
const progress = event ? activityProgress(event) : 0;
|
const progress = event ? activityProgress(event) : 0;
|
||||||
|
const activityStats = event?.activityStats;
|
||||||
const enableQnAOn = session ? effectiveEnableQnA(session) : true;
|
const enableQnAOn = session ? effectiveEnableQnA(session) : true;
|
||||||
const enableQnPOn = session ? effectiveEnableQnP(session) : true;
|
const enableQnPOn = session ? effectiveEnableQnP(session) : true;
|
||||||
const subtype = event?.activitySubtype || "qna";
|
const subtype = event?.activitySubtype || "qna";
|
||||||
|
|
@ -272,6 +274,35 @@ export function ActivityDetailPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activityStats ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(6, minmax(0, 1fr))",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityMetric label="Participants" value={String(activityStats.participantCount ?? 0)} icon={<Users size={14} />} />
|
||||||
|
<ActivityMetric label="Questions" value={String(activityStats.questionCount ?? 0)} icon={<HelpCircle size={14} />} />
|
||||||
|
<ActivityMetric label="Responses" value={String(activityStats.responseCount ?? 0)} icon={<CheckCircle2 size={14} />} />
|
||||||
|
<ActivityMetric
|
||||||
|
label="Event Rating"
|
||||||
|
value={formatActivityRating(activityStats.eventRatingAverage, activityStats.eventRatingCount)}
|
||||||
|
sublabel={activityStats.eventRatingCount ? `${activityStats.eventRatingCount} ratings` : undefined}
|
||||||
|
icon={<Star size={14} />}
|
||||||
|
accent="#d97706"
|
||||||
|
/>
|
||||||
|
<ActivityMetric
|
||||||
|
label="Session Rating"
|
||||||
|
value={formatActivityRating(activityStats.sessionRatingAverage, activityStats.sessionRatingCount)}
|
||||||
|
sublabel={activityStats.sessionRatingCount ? `${activityStats.sessionRatingCount} ratings` : undefined}
|
||||||
|
icon={<Star size={14} />}
|
||||||
|
accent="#d97706"
|
||||||
|
/>
|
||||||
|
<ActivityMetric label="Engagement" value={`${progress}%`} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div style={{ borderBottom: "1px solid var(--border)" }}>
|
<div style={{ borderBottom: "1px solid var(--border)" }}>
|
||||||
<div style={{ display: "flex", gap: isMobile ? "8px" : "0", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: isMobile ? "8px" : "0", flexWrap: "wrap" }}>
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
|
|
@ -435,3 +466,50 @@ export function ActivityDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActivityMetric({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sublabel,
|
||||||
|
icon,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sublabel?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
accent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--card)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
padding: "12px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}>
|
||||||
|
{icon ? <span style={{ color: accent || "var(--muted)", display: "flex" }}>{icon}</span> : null}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-lg)",
|
||||||
|
fontFamily: "var(--font-inter)",
|
||||||
|
fontWeight: "var(--font-weight-medium)",
|
||||||
|
color: accent || "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.4px" }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{sublabel ? (
|
||||||
|
<div style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -520,12 +520,24 @@ export function ActivityEventSettings({
|
||||||
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
||||||
<option>Open</option>
|
<option>Open</option>
|
||||||
<option>Invite Only</option>
|
<option>Invite Only</option>
|
||||||
<option>Approval Required</option>
|
|
||||||
</select>
|
</select>
|
||||||
</SelectWrap>
|
</SelectWrap>
|
||||||
</Field>
|
</Field>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
|
<Field>
|
||||||
|
<Label text="Registration Approval" />
|
||||||
|
<RadioGroup
|
||||||
|
name="registrationApproval"
|
||||||
|
value={form.registrationApproval}
|
||||||
|
onChange={(v) => f("registrationApproval", v as "auto" | "manual")}
|
||||||
|
options={[
|
||||||
|
{ label: "Auto Confirm", value: "auto" },
|
||||||
|
{ label: "Manual Confirm", value: "manual" },
|
||||||
|
]}
|
||||||
|
disabled={form.registrationRequired !== "Yes"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Verify Email" />
|
<Label text="Verify Email" />
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|
@ -538,6 +550,8 @@ export function ActivityEventSettings({
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Registration Deadline" />
|
<Label text="Registration Deadline" />
|
||||||
<input type="date" style={IS} value={form.registrationDeadline} onChange={(e) => f("registrationDeadline", e.target.value)} />
|
<input type="date" style={IS} value={form.registrationDeadline} onChange={(e) => f("registrationDeadline", e.target.value)} />
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search } from "lucide-react";
|
import { X, ChevronDown, ChevronUp, Check, Plus, UserPlus, Search, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
tracksApi,
|
tracksApi,
|
||||||
sessionsApi,
|
sessionsApi,
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type ApiTrack,
|
type ApiTrack,
|
||||||
type ApiSession,
|
type ApiSession,
|
||||||
type SessionPayload,
|
type SessionPayload,
|
||||||
|
type SessionTopic,
|
||||||
type Speaker,
|
type Speaker,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
import type { FormWithSubmissionCount } from "../../../services/api/types";
|
import type { FormWithSubmissionCount } from "../../../services/api/types";
|
||||||
|
|
@ -125,6 +126,38 @@ function StepCircle({ n, state }: { n: number; state: "todo" | "active" | "done"
|
||||||
|
|
||||||
type SectionErr = Record<string, string>;
|
type SectionErr = Record<string, string>;
|
||||||
|
|
||||||
|
type TopicRow = {
|
||||||
|
type: string;
|
||||||
|
duration: string;
|
||||||
|
title: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyTopicRow(): TopicRow {
|
||||||
|
return { type: "", duration: "", title: "", role: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function topicsFromSession(session?: ApiSession | null): TopicRow[] {
|
||||||
|
if (!session?.topics?.length) return [];
|
||||||
|
return session.topics.map((t) => ({
|
||||||
|
type: t.type || "",
|
||||||
|
duration: t.duration || "",
|
||||||
|
title: t.title || "",
|
||||||
|
role: t.role || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTopicsForPayload(rows: TopicRow[]): SessionTopic[] {
|
||||||
|
return rows
|
||||||
|
.map((t) => ({
|
||||||
|
type: t.type.trim(),
|
||||||
|
duration: t.duration.trim(),
|
||||||
|
title: t.title.trim(),
|
||||||
|
role: t.role.trim(),
|
||||||
|
}))
|
||||||
|
.filter((t) => t.type || t.duration || t.title || t.role);
|
||||||
|
}
|
||||||
|
|
||||||
function extractSpeakerIds(s?: ApiSession | null): string[] {
|
function extractSpeakerIds(s?: ApiSession | null): string[] {
|
||||||
if (!s?.speakers?.length) return [];
|
if (!s?.speakers?.length) return [];
|
||||||
return s.speakers.map((sp) =>
|
return s.speakers.map((sp) =>
|
||||||
|
|
@ -201,6 +234,7 @@ export function AgendaSessionPanel({
|
||||||
const [endLocal, setEndLocal] = useState(session?.endTime ? toDatetimeLocalValue(session.endTime) : def.end);
|
const [endLocal, setEndLocal] = useState(session?.endTime ? toDatetimeLocalValue(session.endTime) : def.end);
|
||||||
|
|
||||||
const [description, setDescription] = useState(session?.description || "");
|
const [description, setDescription] = useState(session?.description || "");
|
||||||
|
const [topics, setTopics] = useState<TopicRow[]>(() => topicsFromSession(session));
|
||||||
|
|
||||||
const audienceOn = (v: boolean | undefined) => v !== false;
|
const audienceOn = (v: boolean | undefined) => v !== false;
|
||||||
const [audienceFeedbackEnabled, setAudienceFeedbackEnabled] = useState(() =>
|
const [audienceFeedbackEnabled, setAudienceFeedbackEnabled] = useState(() =>
|
||||||
|
|
@ -341,6 +375,15 @@ export function AgendaSessionPanel({
|
||||||
const [err2, setErr2] = useState<SectionErr>({});
|
const [err2, setErr2] = useState<SectionErr>({});
|
||||||
const [err3, setErr3] = useState<SectionErr>({});
|
const [err3, setErr3] = useState<SectionErr>({});
|
||||||
const [err4, setErr4] = useState<SectionErr>({});
|
const [err4, setErr4] = useState<SectionErr>({});
|
||||||
|
const [err5, setErr5] = useState<SectionErr>({});
|
||||||
|
|
||||||
|
const updateTopic = (idx: number, key: keyof TopicRow, value: string) => {
|
||||||
|
setTopics((prev) => prev.map((row, i) => (i === idx ? { ...row, [key]: value } : row)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTopic = () => setTopics((prev) => [...prev, emptyTopicRow()]);
|
||||||
|
|
||||||
|
const removeTopic = (idx: number) => setTopics((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
const validate1 = useCallback((): boolean => {
|
const validate1 = useCallback((): boolean => {
|
||||||
const e: SectionErr = {};
|
const e: SectionErr = {};
|
||||||
|
|
@ -374,7 +417,15 @@ export function AgendaSessionPanel({
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const validateAll = useCallback(() => validate1() && validate2() && validate3() && validate4(), [validate1, validate2, validate3, validate4]);
|
const validate5 = useCallback((): boolean => {
|
||||||
|
setErr5({});
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validateAll = useCallback(
|
||||||
|
() => validate1() && validate2() && validate3() && validate4() && validate5(),
|
||||||
|
[validate1, validate2, validate3, validate4, validate5],
|
||||||
|
);
|
||||||
|
|
||||||
const createInlineTrack = async () => {
|
const createInlineTrack = async () => {
|
||||||
const e: SectionErr = {};
|
const e: SectionErr = {};
|
||||||
|
|
@ -406,8 +457,9 @@ export function AgendaSessionPanel({
|
||||||
if (section === 2 && !validate2()) return;
|
if (section === 2 && !validate2()) return;
|
||||||
if (section === 3 && !validate3()) return;
|
if (section === 3 && !validate3()) return;
|
||||||
if (section === 4 && !validate4()) return;
|
if (section === 4 && !validate4()) return;
|
||||||
|
if (section === 5 && !validate5()) return;
|
||||||
setPassedSections((prev) => new Set([...prev, section]));
|
setPassedSections((prev) => new Set([...prev, section]));
|
||||||
if (section < 4) setOpenSection(section + 1);
|
if (section < 5) setOpenSection(section + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|
@ -421,10 +473,13 @@ export function AgendaSessionPanel({
|
||||||
if (!validate1()) setOpenSection(1);
|
if (!validate1()) setOpenSection(1);
|
||||||
else if (!validate2()) setOpenSection(2);
|
else if (!validate2()) setOpenSection(2);
|
||||||
else if (!validate3()) setOpenSection(3);
|
else if (!validate3()) setOpenSection(3);
|
||||||
else setOpenSection(4);
|
else if (!validate4()) setOpenSection(4);
|
||||||
|
else setOpenSection(5);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedTopics = normalizeTopicsForPayload(topics);
|
||||||
|
|
||||||
const payload: SessionPayload = {
|
const payload: SessionPayload = {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
|
|
@ -434,6 +489,7 @@ export function AgendaSessionPanel({
|
||||||
status: UI_TO_API_STATUS[status],
|
status: UI_TO_API_STATUS[status],
|
||||||
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
|
track: trackId === UNASSIGNED_TRACK_ID ? null : trackId,
|
||||||
speakers: selectedSpeakerIds,
|
speakers: selectedSpeakerIds,
|
||||||
|
topics: normalizedTopics.length ? normalizedTopics : [],
|
||||||
audienceFeedbackEnabled,
|
audienceFeedbackEnabled,
|
||||||
audienceRatingsEnabled,
|
audienceRatingsEnabled,
|
||||||
audienceKeyPersonsEnabled,
|
audienceKeyPersonsEnabled,
|
||||||
|
|
@ -460,7 +516,8 @@ export function AgendaSessionPanel({
|
||||||
{ n: 1, title: "Track", subtitle: "Assign this session to a track. You can create a new track inline." },
|
{ n: 1, title: "Track", subtitle: "Assign this session to a track. You can create a new track inline." },
|
||||||
{ n: 2, title: "Session details", subtitle: "Title, format, and status." },
|
{ n: 2, title: "Session details", subtitle: "Title, format, and status." },
|
||||||
{ n: 3, title: "Schedule", subtitle: "Start and end date/time." },
|
{ n: 3, title: "Schedule", subtitle: "Start and end date/time." },
|
||||||
{ n: 4, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." },
|
{ n: 4, title: "Session topics", subtitle: "Optional programme items within this session (type, duration, title, role)." },
|
||||||
|
{ n: 5, title: "Speakers & notes", subtitle: "Faculty from your organization; add new speakers as needed." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SaveBtn = () => (
|
const SaveBtn = () => (
|
||||||
|
|
@ -750,6 +807,105 @@ export function AgendaSessionPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sec.n === 4 && (
|
{sec.n === 4 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)", lineHeight: 1.45 }}>
|
||||||
|
Add programme items for this session block — e.g. lectures, panel discussions, and speaker roles.
|
||||||
|
</p>
|
||||||
|
{topics.length === 0 ? (
|
||||||
|
<p style={{ fontSize: "var(--text-sm)", color: "var(--muted)", margin: 0, fontFamily: "var(--font-noto-sans)" }}>
|
||||||
|
No topics yet. Add one below.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{topics.map((topic, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderLeft: "3px solid color-mix(in srgb, var(--primary) 35%, var(--border))",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", fontFamily: "var(--font-noto-sans)" }}>
|
||||||
|
Topic {idx + 1}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTopic(idx)}
|
||||||
|
aria-label="Remove topic"
|
||||||
|
style={{ border: "none", background: "none", cursor: "pointer", color: "var(--destructive)", display: "flex" }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic type</label>
|
||||||
|
<input
|
||||||
|
value={topic.type}
|
||||||
|
onChange={(e) => updateTopic(idx, "type", e.target.value)}
|
||||||
|
placeholder="e.g. LECTURE, Panel Discussion"
|
||||||
|
style={IS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Duration</label>
|
||||||
|
<input
|
||||||
|
value={topic.duration}
|
||||||
|
onChange={(e) => updateTopic(idx, "duration", e.target.value)}
|
||||||
|
placeholder="e.g. 15 or 5+5"
|
||||||
|
style={IS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic title</label>
|
||||||
|
<input
|
||||||
|
value={topic.title}
|
||||||
|
onChange={(e) => updateTopic(idx, "title", e.target.value)}
|
||||||
|
placeholder="Talk or segment title"
|
||||||
|
style={IS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: "var(--text-xs)", display: "block", marginBottom: "4px", fontFamily: "var(--font-noto-sans)" }}>Topic role</label>
|
||||||
|
<input
|
||||||
|
value={topic.role}
|
||||||
|
onChange={(e) => updateTopic(idx, "role", e.target.value)}
|
||||||
|
placeholder="e.g. Moderator, Speaker, Panellist"
|
||||||
|
style={IS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addTopic}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px dashed var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--primary)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add session topic
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sec.n === 5 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Speakers</label>
|
<label style={{ display: "block", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", marginBottom: "6px", fontFamily: "var(--font-noto-sans)" }}>Speakers</label>
|
||||||
|
|
@ -1124,7 +1280,7 @@ export function AgendaSessionPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}>
|
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "16px", paddingTop: "12px", borderTop: "1px solid var(--border)" }}>
|
||||||
{sec.n < 4 ? (
|
{sec.n < 5 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => tryNext(sec.n)}
|
onClick={() => tryNext(sec.n)}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
|
import { usePermission } from "../../contexts/PermissionContext";
|
||||||
import { attendancesApi } from "../../../services/api";
|
import { attendancesApi } from "../../../services/api";
|
||||||
import type {
|
import type {
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
|
|
@ -53,6 +54,10 @@ const emptyForm: FormState = {
|
||||||
|
|
||||||
export function AttendancePage() {
|
export function AttendancePage() {
|
||||||
const { selectedEventId } = useAppNav();
|
const { selectedEventId } = useAppNav();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const canReadAttendance = canModule("attendance", "read");
|
||||||
|
const canWriteAttendance = canModule("attendance", "write");
|
||||||
|
const canDeleteAttendance = canModule("attendance", "delete");
|
||||||
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
|
const [sessions, setSessions] = useState<AttendanceRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -75,7 +80,7 @@ export function AttendancePage() {
|
||||||
const selected = sessions.find((s) => s._id === selectedId) ?? null;
|
const selected = sessions.find((s) => s._id === selectedId) ?? null;
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
if (!selectedEventId) return;
|
if (!selectedEventId || !canReadAttendance) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await attendancesApi.list(selectedEventId);
|
const res = await attendancesApi.list(selectedEventId);
|
||||||
|
|
@ -85,7 +90,7 @@ export function AttendancePage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSessions(res.data?.attendances ?? []);
|
setSessions(res.data?.attendances ?? []);
|
||||||
}, [selectedEventId]);
|
}, [selectedEventId, canReadAttendance]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
|
|
@ -226,6 +231,14 @@ export function AttendancePage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canReadAttendance) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, color: "var(--muted)", fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)" }}>
|
||||||
|
You do not have permission to view Attendance.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -252,6 +265,7 @@ export function AttendancePage() {
|
||||||
Attendance
|
Attendance
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{canWriteAttendance && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreate}
|
onClick={openCreate}
|
||||||
|
|
@ -270,6 +284,7 @@ export function AttendancePage() {
|
||||||
>
|
>
|
||||||
<Plus size={14} /> Add session
|
<Plus size={14} /> Add session
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -370,6 +385,7 @@ export function AttendancePage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{canWriteAttendance && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEdit(selected)}
|
onClick={() => openEdit(selected)}
|
||||||
|
|
@ -378,6 +394,7 @@ export function AttendancePage() {
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
|
<button type="button" onClick={exportCsv} title="Export CSV" style={iconBtn}>
|
||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -390,6 +407,7 @@ export function AttendancePage() {
|
||||||
>
|
>
|
||||||
<Link2 size={14} />
|
<Link2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
{canWriteAttendance && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => regenerateLink(selected._id)}
|
onClick={() => regenerateLink(selected._id)}
|
||||||
|
|
@ -398,9 +416,12 @@ export function AttendancePage() {
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{canDeleteAttendance && (
|
||||||
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
|
<button type="button" onClick={() => archiveSession(selected._id)} title="Archive" style={iconBtn}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -449,11 +470,11 @@ export function AttendancePage() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canCheckIn || checkInBusy === p._id}
|
disabled={!canWriteAttendance || !canCheckIn || checkInBusy === p._id}
|
||||||
onClick={() => handleCheckIn(p._id)}
|
onClick={() => handleCheckIn(p._id)}
|
||||||
style={{
|
style={{
|
||||||
...checkInBtn,
|
...checkInBtn,
|
||||||
opacity: canCheckIn ? 1 : 0.5,
|
opacity: canWriteAttendance && canCheckIn ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<UserCheck size={14} />
|
<UserCheck size={14} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Calendar, MapPin, ExternalLink, Pencil } from "lucide-react";
|
import { Calendar, MapPin, ExternalLink, Pencil, Settings } from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import type { Event } from "../../../services/api/types";
|
import type { Event } from "../../../services/api/types";
|
||||||
import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl";
|
import { defaultMicrositePageUrl, micrositePublicPageUrl } from "../../utils/micrositePublicUrl";
|
||||||
|
|
@ -63,11 +63,13 @@ export function EventHeader({
|
||||||
event,
|
event,
|
||||||
loading = false,
|
loading = false,
|
||||||
onEditEvent,
|
onEditEvent,
|
||||||
|
onWebsiteSettings,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
}: {
|
}: {
|
||||||
event: Event | null;
|
event: Event | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onEditEvent?: () => void;
|
onEditEvent?: () => void;
|
||||||
|
onWebsiteSettings?: () => void;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
|
|
@ -165,6 +167,32 @@ export function EventHeader({
|
||||||
<ExternalLink size={12} /> View Website
|
<ExternalLink size={12} /> View Website
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isMobile && event ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Website settings"
|
||||||
|
aria-label="Website settings"
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => canEdit && onWebsiteSettings?.()}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "30px",
|
||||||
|
height: "30px",
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
background: canEdit ? "rgba(255,255,255,0.15)" : "rgba(255,255,255,0.08)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
color: "#fff",
|
||||||
|
border: "1px solid rgba(255,255,255,0.25)",
|
||||||
|
cursor: canEdit ? "pointer" : "not-allowed",
|
||||||
|
opacity: canEdit ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canEdit || !event}
|
disabled={!canEdit || !event}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Mail, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -9,14 +9,17 @@ import { eventStaffApi } from "../../../services/api/eventStaff";
|
||||||
import { quizPollsApi } from "../../../services/api/quizPolls";
|
import { quizPollsApi } from "../../../services/api/quizPolls";
|
||||||
import { badgeBatchesApi } from "../../../services/api/badgeBatches";
|
import { badgeBatchesApi } from "../../../services/api/badgeBatches";
|
||||||
import { eventTicketsApi } from "../../../services/api/eventTickets";
|
import { eventTicketsApi } from "../../../services/api/eventTickets";
|
||||||
|
import { campaignsApi } from "../../../services/api/campaigns";
|
||||||
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
||||||
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
||||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||||
|
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
|
||||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||||
import { ShareFormPanel } from "../forms/ShareFormPanel";
|
import { ShareFormPanel } from "../forms/ShareFormPanel";
|
||||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||||
|
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||||
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
import type { QuizPollModalForm } from "../quiz/AddQuizPollModal";
|
||||||
import type { Question } from "../quiz/AddQuestionModal";
|
import type { Question } from "../quiz/AddQuestionModal";
|
||||||
|
|
@ -201,8 +204,24 @@ export function EventHomeModules() {
|
||||||
const isSuperAdmin = user?.role === "super_admin";
|
const isSuperAdmin = user?.role === "super_admin";
|
||||||
const canReadForms = canModule("forms", "read");
|
const canReadForms = canModule("forms", "read");
|
||||||
const canWriteForms = canModule("forms", "write");
|
const canWriteForms = canModule("forms", "write");
|
||||||
|
const canReadSessions = canModule("sessions", "read");
|
||||||
const canWriteSessions = canModule("sessions", "write");
|
const canWriteSessions = canModule("sessions", "write");
|
||||||
|
const canReadUserMgmt = canModule("user_management", "read");
|
||||||
const canWriteUserMgmt = canModule("user_management", "write");
|
const canWriteUserMgmt = canModule("user_management", "write");
|
||||||
|
const canReadActivities =
|
||||||
|
canModule("live_polls", "read") || canModule("multiple_quiz_modes", "read");
|
||||||
|
const canWriteActivities =
|
||||||
|
canModule("live_polls", "write") || canModule("multiple_quiz_modes", "write");
|
||||||
|
const canReadBadges = canModule("custom_badge_and_print_file_creation", "read");
|
||||||
|
const canWriteBadges = canModule("custom_badge_and_print_file_creation", "write");
|
||||||
|
const canReadTicketing = canModule("ticketing_free_and_paid", "read");
|
||||||
|
const canReadMicrosite = canModule("event_website", "read");
|
||||||
|
const canReadRewards = canModule("gamification", "read");
|
||||||
|
const canReadWebCheckIn = canModule("web_check_in", "read");
|
||||||
|
const canWriteWebCheckIn = canModule("web_check_in", "write");
|
||||||
|
const canReadDocuments = canModule("content_management", "read");
|
||||||
|
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||||
|
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
const isTablet = bp === "tablet";
|
const isTablet = bp === "tablet";
|
||||||
|
|
@ -244,6 +263,10 @@ export function EventHomeModules() {
|
||||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
|
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
||||||
|
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||||
|
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||||
|
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
|
|
@ -450,6 +473,28 @@ export function EventHomeModules() {
|
||||||
void loadAgendaMeta();
|
void loadAgendaMeta();
|
||||||
}, [loadAgendaMeta]);
|
}, [loadAgendaMeta]);
|
||||||
|
|
||||||
|
const loadEmailCampaignMeta = useCallback(async () => {
|
||||||
|
if (!selectedEventId) {
|
||||||
|
setEmailCampaignCount(0);
|
||||||
|
setEmailCampaignLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailCampaignLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await campaignsApi.listForEvent(selectedEventId);
|
||||||
|
const campaigns = res.data?.campaigns ?? [];
|
||||||
|
setEmailCampaignCount(campaigns.filter((c) => c.channel === "email").length);
|
||||||
|
} catch {
|
||||||
|
setEmailCampaignCount(0);
|
||||||
|
} finally {
|
||||||
|
setEmailCampaignLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedEventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadEmailCampaignMeta();
|
||||||
|
}, [loadEmailCampaignMeta]);
|
||||||
|
|
||||||
const handleBadgeSuccess = () => {
|
const handleBadgeSuccess = () => {
|
||||||
setShowAddBadge(false);
|
setShowAddBadge(false);
|
||||||
setDuplicateBadgeBatchId(null);
|
setDuplicateBadgeBatchId(null);
|
||||||
|
|
@ -528,6 +573,11 @@ export function EventHomeModules() {
|
||||||
: agendaLoading
|
: agendaLoading
|
||||||
? "Loading…"
|
? "Loading…"
|
||||||
: `${agendaCounts.tracks} track${agendaCounts.tracks === 1 ? "" : "s"} · ${agendaCounts.sessions} session${agendaCounts.sessions === 1 ? "" : "s"}`;
|
: `${agendaCounts.tracks} track${agendaCounts.tracks === 1 ? "" : "s"} · ${agendaCounts.sessions} session${agendaCounts.sessions === 1 ? "" : "s"}`;
|
||||||
|
const emailCampaignMeta = !selectedEventId
|
||||||
|
? "Select an event to manage email campaigns"
|
||||||
|
: emailCampaignLoading
|
||||||
|
? "Loading…"
|
||||||
|
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
|
||||||
const webCheckInMeta = !selectedEventId
|
const webCheckInMeta = !selectedEventId
|
||||||
? "Select an event to configure web check-in"
|
? "Select an event to configure web check-in"
|
||||||
: webCheckInLoading
|
: webCheckInLoading
|
||||||
|
|
@ -584,6 +634,7 @@ export function EventHomeModules() {
|
||||||
gap: "12px",
|
gap: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{canReadSessions ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<CalendarDays size={18} />}
|
icon={<CalendarDays size={18} />}
|
||||||
title="Agenda"
|
title="Agenda"
|
||||||
|
|
@ -630,6 +681,7 @@ export function EventHomeModules() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
{canReadForms ? eventForms.map((form) => {
|
{canReadForms ? eventForms.map((form) => {
|
||||||
const linkCopied = copiedFormLinkId === form._id;
|
const linkCopied = copiedFormLinkId === form._id;
|
||||||
return (
|
return (
|
||||||
|
|
@ -776,6 +828,7 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canReadUserMgmt ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<ShieldCheck size={18} />}
|
icon={<ShieldCheck size={18} />}
|
||||||
title="Users & Permissions"
|
title="Users & Permissions"
|
||||||
|
|
@ -824,6 +877,8 @@ export function EventHomeModules() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadActivities ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<BarChart2 size={18} />}
|
icon={<BarChart2 size={18} />}
|
||||||
title="Activities"
|
title="Activities"
|
||||||
|
|
@ -832,20 +887,20 @@ export function EventHomeModules() {
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled || !canWriteActivities}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(),
|
...moduleButtonStyle(),
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowAddPoll(true)}
|
onClick={() => setShowAddPoll(true)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (actionsDisabled) return;
|
if (actionsDisabled || !canWriteActivities) return;
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (actionsDisabled) return;
|
if (actionsDisabled || !canWriteActivities) return;
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||||
}}
|
}}
|
||||||
|
|
@ -854,11 +909,11 @@ export function EventHomeModules() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled || !canWriteActivities}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(true),
|
...moduleButtonStyle(true),
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
opacity: actionsDisabled || !canWriteActivities ? 0.55 : 1,
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
cursor: actionsDisabled || !canWriteActivities ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowAddQuiz(true)}
|
onClick={() => setShowAddQuiz(true)}
|
||||||
>
|
>
|
||||||
|
|
@ -867,6 +922,8 @@ export function EventHomeModules() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadBadges ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Tag size={18} />}
|
icon={<Tag size={18} />}
|
||||||
title="Tag & Badge Configuration"
|
title="Tag & Badge Configuration"
|
||||||
|
|
@ -895,6 +952,7 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
View Badges
|
View Badges
|
||||||
</button>
|
</button>
|
||||||
|
{canWriteBadges ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
|
|
@ -907,9 +965,12 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
Add Badge
|
Add Badge
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadTicketing ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Ticket size={18} />}
|
icon={<Ticket size={18} />}
|
||||||
title="Ticket configuration"
|
title="Ticket configuration"
|
||||||
|
|
@ -929,6 +990,8 @@ export function EventHomeModules() {
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadMicrosite ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Globe size={18} />}
|
icon={<Globe size={18} />}
|
||||||
title="Microsite"
|
title="Microsite"
|
||||||
|
|
@ -948,6 +1011,8 @@ export function EventHomeModules() {
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadRewards ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Trophy size={18} />}
|
icon={<Trophy size={18} />}
|
||||||
title="Reward Points Config"
|
title="Reward Points Config"
|
||||||
|
|
@ -967,6 +1032,8 @@ export function EventHomeModules() {
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadWebCheckIn ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Plus size={18} />}
|
icon={<Plus size={18} />}
|
||||||
title="Web check-in"
|
title="Web check-in"
|
||||||
|
|
@ -976,12 +1043,12 @@ export function EventHomeModules() {
|
||||||
webCheckInEnabled ? (
|
webCheckInEnabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled || webCheckInLoading}
|
disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(),
|
...moduleButtonStyle(),
|
||||||
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
|
||||||
cursor:
|
cursor:
|
||||||
actionsDisabled || webCheckInLoading
|
actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
|
||||||
? "not-allowed"
|
? "not-allowed"
|
||||||
: "pointer",
|
: "pointer",
|
||||||
}}
|
}}
|
||||||
|
|
@ -992,12 +1059,12 @@ export function EventHomeModules() {
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled || webCheckInLoading}
|
disabled={actionsDisabled || webCheckInLoading || !canWriteWebCheckIn}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(true),
|
...moduleButtonStyle(true),
|
||||||
opacity: actionsDisabled || webCheckInLoading ? 0.55 : 1,
|
opacity: actionsDisabled || webCheckInLoading || !canWriteWebCheckIn ? 0.55 : 1,
|
||||||
cursor:
|
cursor:
|
||||||
actionsDisabled || webCheckInLoading
|
actionsDisabled || webCheckInLoading || !canWriteWebCheckIn
|
||||||
? "not-allowed"
|
? "not-allowed"
|
||||||
: "pointer",
|
: "pointer",
|
||||||
}}
|
}}
|
||||||
|
|
@ -1008,6 +1075,8 @@ export function EventHomeModules() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadDocuments ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Upload size={18} />}
|
icon={<Upload size={18} />}
|
||||||
title="Upload Document"
|
title="Upload Document"
|
||||||
|
|
@ -1027,6 +1096,52 @@ export function EventHomeModules() {
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadEmailCampaigns ? (
|
||||||
|
<ModuleCard
|
||||||
|
icon={<Mail size={18} />}
|
||||||
|
title="Email Campaign"
|
||||||
|
meta={emailCampaignMeta}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowEmailCampaignsPanel(true)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (actionsDisabled) return;
|
||||||
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (actionsDisabled) return;
|
||||||
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || !canWriteEmailCampaigns}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
opacity: actionsDisabled || !canWriteEmailCampaigns ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled || !canWriteEmailCampaigns ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowCreateEmailCampaign(true)}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{formShareTarget ? (
|
{formShareTarget ? (
|
||||||
|
|
@ -1127,6 +1242,23 @@ export function EventHomeModules() {
|
||||||
onClose={() => setShowDocumentsPanel(false)}
|
onClose={() => setShowDocumentsPanel(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showEmailCampaignsPanel && selectedEventId ? (
|
||||||
|
<EventEmailCampaignsPanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
eventName={eventName ?? undefined}
|
||||||
|
onClose={() => setShowEmailCampaignsPanel(false)}
|
||||||
|
onChanged={() => void loadEmailCampaignMeta()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showCreateEmailCampaign && selectedEventId ? (
|
||||||
|
<CreateEmailCampaignPanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
onClose={() => setShowCreateEmailCampaign(false)}
|
||||||
|
onSuccess={() => void loadEmailCampaignMeta()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
Palette, Mic2, Users, FileText, BookOpen, MessageSquare,
|
Palette, Mic2, Users, FileText, BookOpen, MessageSquare,
|
||||||
Shield, Image, Layout, Tag, Mail,
|
Shield, Image, Layout, Tag, Mail,
|
||||||
Plus, Search, X, MoreHorizontal, Upload,
|
Plus, Search, X, MoreHorizontal, Upload,
|
||||||
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
|
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, RotateCcw,
|
||||||
Link2, List, Check, Share2,
|
Link2, List, Check, Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
|
|
@ -46,6 +46,7 @@ import type {
|
||||||
EventMicrosite,
|
EventMicrosite,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
import { LoadingState } from "../shared";
|
import { LoadingState } from "../shared";
|
||||||
|
import { buildEmailerPreviewHtml } from "../../utils/emailerPreview";
|
||||||
import { ConfirmModal } from "../shared";
|
import { ConfirmModal } from "../shared";
|
||||||
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
|
import { AddSpeakerPanel } from "../panels/AddSpeakerPanel";
|
||||||
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
|
import { CreateCustomListPanel } from "../panels/CreateCustomListPanel";
|
||||||
|
|
@ -147,6 +148,7 @@ function formatLastEdited(updatedAt?: string): string {
|
||||||
|
|
||||||
export function DesignPage({ subPage }: { subPage?: string }) {
|
export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { canModule } = usePermission();
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
const activeTab = subPage === "design-brochure" ? "Brochure"
|
const activeTab = subPage === "design-brochure" ? "Brochure"
|
||||||
|
|
@ -156,6 +158,8 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
: "Brochure";
|
: "Brochure";
|
||||||
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
|
const apiType = (subPage && SUBPAGE_TO_TYPE[subPage]) || "brochure";
|
||||||
const isSuperAdmin = user?.role === "super_admin";
|
const isSuperAdmin = user?.role === "super_admin";
|
||||||
|
const canWriteEmailers = isSuperAdmin || canModule("email_outreach_campaigns", "write");
|
||||||
|
const canManageEmailers = apiType === "emailer" ? canWriteEmailers : isSuperAdmin;
|
||||||
const canManageMicrosite =
|
const canManageMicrosite =
|
||||||
apiType === "microsite" &&
|
apiType === "microsite" &&
|
||||||
user &&
|
user &&
|
||||||
|
|
@ -371,6 +375,9 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") {
|
if (c != null && typeof c === "object" && "html" in c && typeof (c as { html?: unknown }).html === "string") {
|
||||||
html = String((c as { html: string }).html || "");
|
html = String((c as { html: string }).html || "");
|
||||||
}
|
}
|
||||||
|
if (!html.trim() && c != null && typeof c === "object") {
|
||||||
|
html = buildEmailerPreviewHtml(c as Record<string, unknown>);
|
||||||
|
}
|
||||||
if (!html.trim()) {
|
if (!html.trim()) {
|
||||||
html =
|
html =
|
||||||
'<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>';
|
'<p style="font-family:system-ui,sans-serif;padding:24px;color:#64748b">No HTML saved for this template yet. Use Edit to build the emailer.</p>';
|
||||||
|
|
@ -462,7 +469,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
<Upload size={13} /> Import
|
<Upload size={13} /> Import
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : isSuperAdmin) && (
|
{(apiType === "microsite" ? canManageMicrosite : apiType === "badge" ? canManageBadgeBatch : canManageEmailers) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
|
style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", transition: "opacity 0.15s", flex: isMobile ? 1 : "none", justifyContent: isMobile ? "center" : "flex-start" }}
|
||||||
|
|
@ -507,7 +514,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
{templateIcons[activeTab]}
|
{templateIcons[activeTab]}
|
||||||
{hovered === t._id && (
|
{hovered === t._id && (
|
||||||
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
|
<div style={{ position: "absolute", inset: 0, background: "rgba(11,77,209,0.08)", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", flexWrap: "wrap" }}>
|
||||||
{isSuperAdmin && (
|
{(isSuperAdmin || (apiType === "emailer" && canWriteEmailers)) && (
|
||||||
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button>
|
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>Edit</button>
|
||||||
)}
|
)}
|
||||||
{apiType === "brochure" ? (
|
{apiType === "brochure" ? (
|
||||||
|
|
@ -552,7 +559,7 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
{emailerPreviewBusyId === t._id ? "…" : "Preview"}
|
{emailerPreviewBusyId === t._id ? "…" : "Preview"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{isSuperAdmin && (
|
{((isSuperAdmin && apiType !== "emailer") || (apiType === "emailer" && canWriteEmailers && t.canDelete !== false && !t.isSystemTemplate)) && (
|
||||||
<button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button>
|
<button onClick={e => { e.stopPropagation(); setDeleteConfirm(t); }} style={{ padding: "5px 12px", borderRadius: "var(--radius-button)", background: "var(--destructive)", color: "#fff", border: "none", cursor: "pointer", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)" }}>Delete</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -561,13 +568,21 @@ export function DesignPage({ subPage }: { subPage?: string }) {
|
||||||
<div style={{ padding: "10px 12px" }}>
|
<div style={{ padding: "10px 12px" }}>
|
||||||
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "2px" }}>{t.name}</p>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "4px" }}>
|
||||||
|
<span style={{ 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>
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>{t.category || "—"}</span>
|
||||||
|
{apiType === "emailer" && t.isSystemTemplate ? (
|
||||||
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--primary)", background: "var(--accent)", padding: "1px 6px", borderRadius: "3px" }}>Global</span>
|
||||||
|
) : null}
|
||||||
|
{apiType === "emailer" && t.isOrgOverride ? (
|
||||||
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", background: "var(--secondary)", padding: "1px 6px", borderRadius: "3px" }}>Customized</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span>
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{formatLastEdited(t.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isSuperAdmin && (
|
{canManageEmailers && (
|
||||||
<div
|
<div
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
|
style={{ border: "2px dashed var(--border)", borderRadius: "var(--radius-card)", minHeight: "170px", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px", cursor: "pointer", transition: "border-color 0.12s, background 0.12s" }}
|
||||||
|
|
@ -1070,6 +1085,8 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
|
||||||
? "Checked In"
|
? "Checked In"
|
||||||
: statusRaw === "confirmed"
|
: statusRaw === "confirmed"
|
||||||
? "Confirmed"
|
? "Confirmed"
|
||||||
|
: statusRaw === "rejected"
|
||||||
|
? "Rejected"
|
||||||
: "Pending";
|
: "Pending";
|
||||||
const ev = p.event as { _id?: string; name?: string } | undefined;
|
const ev = p.event as { _id?: string; name?: string } | undefined;
|
||||||
const eventName = ev?.name ?? "—";
|
const eventName = ev?.name ?? "—";
|
||||||
|
|
@ -1079,6 +1096,9 @@ function toAllParticipantRow(p: ParticipantWithEvent) {
|
||||||
|
|
||||||
export function AllParticipantsPage() {
|
export function AllParticipantsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const canWriteParticipants = canModule("participants", "write");
|
||||||
|
const canDeleteParticipants = canModule("participants", "delete");
|
||||||
const listOrgId = portfolioListOrganizationId(user);
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
const isSuperAdmin = user?.role === "super_admin";
|
const isSuperAdmin = user?.role === "super_admin";
|
||||||
const { setActiveNav } = useAppNav();
|
const { setActiveNav } = useAppNav();
|
||||||
|
|
@ -1089,6 +1109,7 @@ export function AllParticipantsPage() {
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||||
|
const [showDeleted, setShowDeleted] = useState(false);
|
||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -1100,6 +1121,7 @@ export function AllParticipantsPage() {
|
||||||
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
const [tagOptions, setTagOptions] = useState<string[]>([]);
|
||||||
const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
|
const [editParticipantId, setEditParticipantId] = useState<string | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const searchDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -1131,17 +1153,18 @@ export function AllParticipantsPage() {
|
||||||
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
useEffect(() => { setPage(1); }, [search, activeTag]);
|
useEffect(() => { setPage(1); }, [search, activeTag, showDeleted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!listOrgId) {
|
if (!listOrgId) {
|
||||||
setTagOptions([]);
|
setTagOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (showDeleted) return;
|
||||||
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
|
participantsApi.listDistinctTags({ organization: listOrgId }).then((r) => {
|
||||||
if (r.data?.tags?.length) setTagOptions(r.data.tags);
|
if (r.data?.tags?.length) setTagOptions(r.data.tags);
|
||||||
});
|
});
|
||||||
}, [listOrgId, listNonce]);
|
}, [listOrgId, listNonce, showDeleted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!listOrgId) {
|
if (!listOrgId) {
|
||||||
|
|
@ -1157,7 +1180,8 @@ export function AllParticipantsPage() {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
q: search || undefined,
|
q: search || undefined,
|
||||||
tag: activeTag ?? undefined,
|
tag: showDeleted ? undefined : (activeTag ?? undefined),
|
||||||
|
deleted: showDeleted || undefined,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -1167,7 +1191,7 @@ export function AllParticipantsPage() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
}, [listOrgId, page, limit, search, activeTag, listNonce]);
|
}, [listOrgId, page, limit, search, activeTag, listNonce, showDeleted]);
|
||||||
|
|
||||||
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
|
const clearFilters = () => { setSearchInput(""); setSearch(""); setActiveTag(null); setPage(1); };
|
||||||
|
|
||||||
|
|
@ -1214,12 +1238,24 @@ export function AllParticipantsPage() {
|
||||||
setImportMsg(res.error);
|
setImportMsg(res.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setImportMsg(`Removed ${name}.`);
|
setImportMsg(`Moved ${name} to Deleted.`);
|
||||||
setSelectAllMatchingActive(false);
|
setSelectAllMatchingActive(false);
|
||||||
setSelectedIds((prev) => prev.filter((x) => x !== id));
|
setSelectedIds((prev) => prev.filter((x) => x !== id));
|
||||||
setListNonce((n) => n + 1);
|
setListNonce((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restoreOne = async (id: string, name: string) => {
|
||||||
|
setRestoringId(id);
|
||||||
|
const res = await participantsApi.restore(id);
|
||||||
|
setRestoringId(null);
|
||||||
|
if (res.error) {
|
||||||
|
setImportMsg(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportMsg(`Restored ${name}.`);
|
||||||
|
setListNonce((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||||
{importMsg && (
|
{importMsg && (
|
||||||
|
|
@ -1233,25 +1269,57 @@ export function AllParticipantsPage() {
|
||||||
<p style={labelStyle}>Portfolio</p>
|
<p style={labelStyle}>Portfolio</p>
|
||||||
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>Participants</h1>
|
||||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>
|
||||||
{total} in your organization (Including people not yet assigned to an event) · Page {page} of {Math.max(1, Math.ceil(total / limit))}
|
{total} {showDeleted ? "deleted" : "active"} in your organization · Page {page} of {Math.max(1, Math.ceil(total / limit))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
|
||||||
<button type="button" disabled={exporting} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting ? 0.7 : 1 }}>
|
<button type="button" disabled={exporting || showDeleted} onClick={runExportAll} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: exporting ? "wait" : "pointer", opacity: exporting || showDeleted ? 0.7 : 1 }}>
|
||||||
<Download size={13} /> {exporting ? "Exporting…" : "Export"}
|
<Download size={13} /> {exporting ? "Exporting…" : "Export"}
|
||||||
</button>
|
</button>
|
||||||
|
{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" }}>
|
<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
|
<Upload size={13} /> Import
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
<button type="button" onClick={() => setActiveNav("participant-lists")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--secondary)", color: "var(--card-foreground)", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}>
|
||||||
Custom lists
|
Custom lists
|
||||||
</button>
|
</button>
|
||||||
|
{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)" }}>
|
<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
|
<Plus size={13} /> Create Custom List
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active / Deleted toggle */}
|
||||||
|
<div style={{ display: "inline-flex", alignItems: "center", gap: "0", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", overflow: "hidden", alignSelf: "flex-start" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowDeleted(false); clearSelection(); }}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px", border: "none", cursor: "pointer",
|
||||||
|
background: !showDeleted ? "var(--primary)" : "var(--card)",
|
||||||
|
color: !showDeleted ? "var(--primary-foreground)" : "var(--card-foreground)",
|
||||||
|
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowDeleted(true); clearSelection(); setActiveTag(null); }}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px", border: "none", cursor: "pointer",
|
||||||
|
background: showDeleted ? "var(--primary)" : "var(--card)",
|
||||||
|
color: showDeleted ? "var(--primary-foreground)" : "var(--card-foreground)",
|
||||||
|
fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deleted
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filter row */}
|
{/* Filter row */}
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
|
||||||
<ParticipantSelectAllMatching
|
<ParticipantSelectAllMatching
|
||||||
|
|
@ -1280,23 +1348,29 @@ export function AllParticipantsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!showDeleted && (
|
||||||
<ParticipantBulkActionBar
|
<ParticipantBulkActionBar
|
||||||
selectedCount={selectedIds.length}
|
selectedCount={selectedIds.length}
|
||||||
busy={bulk.bulkBusy}
|
busy={bulk.bulkBusy}
|
||||||
variant="portfolio"
|
variant="portfolio"
|
||||||
onAddTags={() => void bulk.openBulkDialog("addTags")}
|
onAddTags={canWriteParticipants ? () => void bulk.openBulkDialog("addTags") : undefined}
|
||||||
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
|
onRemoveTags={canWriteParticipants ? () => void bulk.openBulkDialog("removeTags") : undefined}
|
||||||
onAssignEvents={() => void bulk.openBulkDialog("assignEvents")}
|
onAssignEvents={canWriteParticipants ? () => void bulk.openBulkDialog("assignEvents") : undefined}
|
||||||
onNewList={() => void bulk.openBulkDialog("newList")}
|
onNewList={canWriteParticipants ? () => void bulk.openBulkDialog("newList") : undefined}
|
||||||
onAddToList={() => void bulk.openBulkDialog("addToList")}
|
onAddToList={canWriteParticipants ? () => void bulk.openBulkDialog("addToList") : undefined}
|
||||||
onDeleteSelected={() => { if (window.confirm(`Delete ${selectedIds.length} participant(s) permanently?`)) void bulk.runBulkDelete(); }}
|
onDeleteSelected={
|
||||||
|
canDeleteParticipants
|
||||||
|
? () => { if (window.confirm(`Move ${selectedIds.length} participant(s) to Deleted?`)) void bulk.runBulkDelete(); }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<ScrollTable minWidth={720}>
|
<ScrollTable minWidth={720}>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", alignItems: "center" }}>
|
||||||
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
|
<input ref={headerSelectRef} type="checkbox" title="Select visible rows" onChange={toggleAllVisible} disabled={showDeleted} style={{ width: "16px", height: "16px", cursor: showDeleted ? "not-allowed" : "pointer", opacity: showDeleted ? 0.4 : 1 }} />
|
||||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span>
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Name</span>
|
||||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span>
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Email</span>
|
||||||
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span>
|
<span style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600", color: "var(--muted)", textTransform: "uppercase" }}>Tags</span>
|
||||||
|
|
@ -1309,10 +1383,12 @@ export function AllParticipantsPage() {
|
||||||
<div style={{ padding: "40px", textAlign: "center" }}>
|
<div style={{ padding: "40px", textAlign: "center" }}>
|
||||||
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
|
<Search size={22} style={{ color: "var(--border)", margin: "0 auto 10px" }} />
|
||||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", marginBottom: "4px" }}>
|
||||||
No participants found
|
{showDeleted ? "No deleted participants" : "No participants found"}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginBottom: "14px" }}>
|
||||||
{(search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
|
{showDeleted
|
||||||
|
? "Soft-deleted people will appear here so you can restore them."
|
||||||
|
: (search || activeTag) ? "Try adjusting your filters or import participants." : "No one in your organization yet. Import a CSV or add people from an event."}
|
||||||
</p>
|
</p>
|
||||||
{(search || activeTag) && (
|
{(search || activeTag) && (
|
||||||
<button type="button" onClick={clearFilters}
|
<button type="button" onClick={clearFilters}
|
||||||
|
|
@ -1327,14 +1403,14 @@ export function AllParticipantsPage() {
|
||||||
onMouseLeave={() => setHovered(null)}
|
onMouseLeave={() => setHovered(null)}
|
||||||
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
style={{ display: "grid", gridTemplateColumns: "38px 1.1fr 1.35fr minmax(100px,1fr) 1fr 92px", alignItems: "center", padding: "11px 16px", borderBottom: "1px solid var(--border)", background: hovered === p.rowKey ? "var(--secondary)" : "var(--card)", transition: "background 0.12s" }}
|
||||||
>
|
>
|
||||||
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} style={{ width: "16px", height: "16px", cursor: "pointer" }} />
|
<input type="checkbox" checked={selectedIds.includes(p.id)} onChange={() => toggleRow(p.id)} disabled={showDeleted} style={{ width: "16px", height: "16px", cursor: showDeleted ? "not-allowed" : "pointer", opacity: showDeleted ? 0.4 : 1 }} />
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
|
||||||
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
<div style={{ width: "28px", height: "28px", borderRadius: "50%", background: "var(--primary)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||||
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
|
<span style={{ color: "#fff", fontSize: "9px", fontFamily: "var(--font-inter)", fontWeight: "600" }}>{(p.name.slice(0, 2) || "?").toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{p.name}</p>
|
||||||
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
|
<p style={{ fontSize: "10px", fontFamily: "var(--font-noto-sans)", color: p.status === "—" || p.status === "Cancelled" || p.status === "Rejected" ? "var(--muted)" : "#15803d" }}>{p.status}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span>
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.email}</span>
|
||||||
|
|
@ -1347,12 +1423,32 @@ export function AllParticipantsPage() {
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
|
<span style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-sm)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.eventName}</span>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "flex-end" }}>
|
||||||
|
{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" }}>
|
<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} />
|
<Pencil size={13} />
|
||||||
</button>
|
</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" }}>
|
<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} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1420,7 +1516,7 @@ export function AllParticipantsPage() {
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title="Delete participant?"
|
title="Delete participant?"
|
||||||
message={`Remove ${deleteTarget.name} from your organization? This cannot be undone.`}
|
message={`Move ${deleteTarget.name} to Deleted? You can restore them later from the Deleted tab.`}
|
||||||
confirmLabel="Delete"
|
confirmLabel="Delete"
|
||||||
onConfirm={() => void confirmDeleteOne()}
|
onConfirm={() => void confirmDeleteOne()}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
|
@ -1435,6 +1531,7 @@ export function AllParticipantsPage() {
|
||||||
onToggleTag={bulk.toggleSelectedTag}
|
onToggleTag={bulk.toggleSelectedTag}
|
||||||
availableTags={tagOptions}
|
availableTags={tagOptions}
|
||||||
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||||
|
tagScope="global"
|
||||||
newListName={bulk.newListName}
|
newListName={bulk.newListName}
|
||||||
onNewListNameChange={bulk.setNewListName}
|
onNewListNameChange={bulk.setNewListName}
|
||||||
newListDesc={bulk.newListDesc}
|
newListDesc={bulk.newListDesc}
|
||||||
|
|
@ -1478,6 +1575,7 @@ function formatListMemberStatus(s: string | undefined): string {
|
||||||
if (raw === "checked_in") return "Checked In";
|
if (raw === "checked_in") return "Checked In";
|
||||||
if (raw === "confirmed") return "Confirmed";
|
if (raw === "confirmed") return "Confirmed";
|
||||||
if (raw === "pending") return "Pending";
|
if (raw === "pending") return "Pending";
|
||||||
|
if (raw === "rejected") return "Rejected";
|
||||||
return String(s);
|
return String(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1392,15 +1392,40 @@ function EmailDeliverySection() {
|
||||||
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>}
|
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: 0 }}>{err}</p>}
|
||||||
{msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>}
|
{msg && <p style={{ color: "#15803d", fontSize: "var(--text-sm)", margin: 0 }}>{msg}</p>}
|
||||||
<ToggleRow value={enabled} onChange={setEnabled} label="Use custom SMTP" />
|
<ToggleRow value={enabled} onChange={setEnabled} label="Use custom SMTP" />
|
||||||
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0, lineHeight: 1.45 }}>
|
||||||
|
Microsoft 365 / Outlook: host <code>smtp.office365.com</code>, port <code>587</code>, SSL off.
|
||||||
|
Username = full mailbox email. Authenticated SMTP must be enabled for that user; with MFA use an App Password.
|
||||||
|
Save settings, then re-enter the password if login still fails.
|
||||||
|
</p>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="SMTP host" />
|
<Label text="SMTP host" />
|
||||||
<input style={IS} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" onFocus={focusIn} onBlur={focusOut} />
|
<input style={IS} value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.office365.com" onFocus={focusIn} onBlur={focusOut} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Port" />
|
<Label text="Port" />
|
||||||
<input style={IS} type="number" value={port} onChange={(e) => setPort(parseInt(e.target.value, 10) || 587)} onFocus={focusIn} onBlur={focusOut} />
|
<input
|
||||||
|
style={IS}
|
||||||
|
type="number"
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = parseInt(e.target.value, 10) || 587;
|
||||||
|
setPort(next);
|
||||||
|
// Match common provider defaults so SSL mode matches the port.
|
||||||
|
if (next === 465) setSecure(true);
|
||||||
|
else if (next === 587 || next === 25 || next === 2525) setSecure(false);
|
||||||
|
}}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "4px 0 0" }}>
|
||||||
|
Use 587 with STARTTLS (SSL off), or 465 with SSL/TLS on. Mixing these causes “wrong version number” errors.
|
||||||
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
<ToggleRow value={secure} onChange={setSecure} label="Use SSL/TLS (e.g. port 465)" />
|
<ToggleRow
|
||||||
|
value={secure}
|
||||||
|
onChange={setSecure}
|
||||||
|
label="Use SSL/TLS (port 465 only — leave off for 587)"
|
||||||
|
/>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Username" />
|
<Label text="Username" />
|
||||||
<input style={IS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
<input style={IS} value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||||
|
|
|
||||||
|
|
@ -10,25 +10,14 @@ import {
|
||||||
type Event as ApiEvent,
|
type Event as ApiEvent,
|
||||||
type ParticipantListRecord,
|
type ParticipantListRecord,
|
||||||
type DesignTemplate,
|
type DesignTemplate,
|
||||||
|
type CampaignRecipients,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { RichEmailEditor } from "./RichEmailEditor";
|
import { RichEmailEditor } from "./RichEmailEditor";
|
||||||
import { PanelShell } from "../panels/PanelShell";
|
import { PanelShell, RadioGroup, SelectWrap, IS, SS, focusIn, focusOut } from "../panels/PanelShell";
|
||||||
import { EMAIL_PLACEHOLDERS } from "./mergePlaceholders";
|
import { EMAIL_PLACEHOLDERS } from "./mergePlaceholders";
|
||||||
|
import { PARTICIPANT_STATUS, type ParticipantStatus } from "../../../config/constants";
|
||||||
const IS: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--radius-button)",
|
|
||||||
background: "var(--input-background)",
|
|
||||||
fontSize: "var(--text-sm)",
|
|
||||||
fontFamily: "var(--font-noto-sans)",
|
|
||||||
color: "var(--card-foreground)",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
};
|
|
||||||
|
|
||||||
function eventLabel(c: MessagingCampaign): string {
|
function eventLabel(c: MessagingCampaign): string {
|
||||||
const e = c.event;
|
const e = c.event;
|
||||||
|
|
@ -36,6 +25,36 @@ function eventLabel(c: MessagingCampaign): string {
|
||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<ParticipantStatus, string> = {
|
||||||
|
pending: "Pending",
|
||||||
|
confirmed: "Confirmed",
|
||||||
|
checked_in: "Checked In",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
rejected: "Rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecipientSource = "participant_list" | "participants";
|
||||||
|
|
||||||
|
function buildRecipients(
|
||||||
|
source: RecipientSource,
|
||||||
|
listId: string,
|
||||||
|
status: "" | ParticipantStatus,
|
||||||
|
): CampaignRecipients | { error: string } {
|
||||||
|
if (source === "participant_list") {
|
||||||
|
if (!listId) return { error: "Select a contact list." };
|
||||||
|
return { type: "participant_list", participantList: listId };
|
||||||
|
}
|
||||||
|
if (!status) return { type: "all" };
|
||||||
|
return { type: "filtered", filters: { status } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFilterStatus(filters: CampaignRecipients["filters"]): "" | ParticipantStatus {
|
||||||
|
if (!filters || typeof filters !== "object") return "";
|
||||||
|
const raw = (filters as Record<string, unknown>).status;
|
||||||
|
if (typeof raw !== "string") return "";
|
||||||
|
return (PARTICIPANT_STATUS as readonly string[]).includes(raw) ? (raw as ParticipantStatus) : "";
|
||||||
|
}
|
||||||
|
|
||||||
export function MessagingEmailPage() {
|
export function MessagingEmailPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { setActiveNav, setSettingsInitialSection } = useAppNav();
|
const { setActiveNav, setSettingsInitialSection } = useAppNav();
|
||||||
|
|
@ -51,7 +70,9 @@ export function MessagingEmailPage() {
|
||||||
|
|
||||||
const [formEventId, setFormEventId] = useState("");
|
const [formEventId, setFormEventId] = useState("");
|
||||||
const [formName, setFormName] = useState("");
|
const [formName, setFormName] = useState("");
|
||||||
|
const [recipientSource, setRecipientSource] = useState<RecipientSource>("participant_list");
|
||||||
const [formListId, setFormListId] = useState("");
|
const [formListId, setFormListId] = useState("");
|
||||||
|
const [formStatus, setFormStatus] = useState<"" | ParticipantStatus>("");
|
||||||
const [msgMode, setMsgMode] = useState<"manual" | "template">("template");
|
const [msgMode, setMsgMode] = useState<"manual" | "template">("template");
|
||||||
const [formTemplateId, setFormTemplateId] = useState("");
|
const [formTemplateId, setFormTemplateId] = useState("");
|
||||||
const [formSubject, setFormSubject] = useState("");
|
const [formSubject, setFormSubject] = useState("");
|
||||||
|
|
@ -98,7 +119,9 @@ export function MessagingEmailPage() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setFormEventId(events[0]?._id || "");
|
setFormEventId(events[0]?._id || "");
|
||||||
setFormName("");
|
setFormName("");
|
||||||
|
setRecipientSource("participant_list");
|
||||||
setFormListId(lists[0]?._id || "");
|
setFormListId(lists[0]?._id || "");
|
||||||
|
setFormStatus("");
|
||||||
setMsgMode("template");
|
setMsgMode("template");
|
||||||
setFormTemplateId(templates[0]?._id || "");
|
setFormTemplateId(templates[0]?._id || "");
|
||||||
setFormSubject("");
|
setFormSubject("");
|
||||||
|
|
@ -114,7 +137,16 @@ export function MessagingEmailPage() {
|
||||||
const eid = typeof c.event === "string" ? c.event : c.event?._id;
|
const eid = typeof c.event === "string" ? c.event : c.event?._id;
|
||||||
setFormEventId(eid || "");
|
setFormEventId(eid || "");
|
||||||
setFormName(c.name);
|
setFormName(c.name);
|
||||||
setFormListId(c.recipients?.participantList || "");
|
const recType = c.recipients?.type;
|
||||||
|
if (recType === "all" || recType === "filtered") {
|
||||||
|
setRecipientSource("participants");
|
||||||
|
setFormListId(lists[0]?._id || "");
|
||||||
|
setFormStatus(recType === "filtered" ? readFilterStatus(c.recipients?.filters) : "");
|
||||||
|
} else {
|
||||||
|
setRecipientSource("participant_list");
|
||||||
|
setFormListId(c.recipients?.participantList || lists[0]?._id || "");
|
||||||
|
setFormStatus("");
|
||||||
|
}
|
||||||
const hasTpl = Boolean(c.content?.template);
|
const hasTpl = Boolean(c.content?.template);
|
||||||
setMsgMode(hasTpl ? "template" : "manual");
|
setMsgMode(hasTpl ? "template" : "manual");
|
||||||
setFormTemplateId(c.content?.template || "");
|
setFormTemplateId(c.content?.template || "");
|
||||||
|
|
@ -125,15 +157,22 @@ export function MessagingEmailPage() {
|
||||||
setPanelOpen(true);
|
setPanelOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveRecipients = (): CampaignRecipients | null => {
|
||||||
|
const built = buildRecipients(recipientSource, formListId, formStatus);
|
||||||
|
if ("error" in built) {
|
||||||
|
setErr(built.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return built;
|
||||||
|
};
|
||||||
|
|
||||||
const saveDraft = async (): Promise<string | null> => {
|
const saveDraft = async (): Promise<string | null> => {
|
||||||
if (!formEventId || !formName.trim()) {
|
if (!formEventId || !formName.trim()) {
|
||||||
setErr("Event and campaign name are required.");
|
setErr("Event and campaign name are required.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!formListId) {
|
const recipients = resolveRecipients();
|
||||||
setErr("Select a contact list.");
|
if (!recipients) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setErr("");
|
setErr("");
|
||||||
if (msgMode === "template" && !formTemplateId) {
|
if (msgMode === "template" && !formTemplateId) {
|
||||||
|
|
@ -149,7 +188,6 @@ export function MessagingEmailPage() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const recipients = { type: "participant_list" as const, participantList: formListId };
|
|
||||||
const content =
|
const content =
|
||||||
msgMode === "template"
|
msgMode === "template"
|
||||||
? { subject: "", body: "", template: formTemplateId }
|
? { subject: "", body: "", template: formTemplateId }
|
||||||
|
|
@ -196,10 +234,8 @@ export function MessagingEmailPage() {
|
||||||
setErr("Event and campaign name are required.");
|
setErr("Event and campaign name are required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formListId) {
|
const recipients = resolveRecipients();
|
||||||
setErr("Select a contact list.");
|
if (!recipients) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msgMode === "template" && !formTemplateId) {
|
if (msgMode === "template" && !formTemplateId) {
|
||||||
setErr("Select an emailer template.");
|
setErr("Select an emailer template.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -213,7 +249,6 @@ export function MessagingEmailPage() {
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setErr("");
|
setErr("");
|
||||||
const recipients = { type: "participant_list" as const, participantList: formListId };
|
|
||||||
const content =
|
const content =
|
||||||
msgMode === "template"
|
msgMode === "template"
|
||||||
? { subject: "", body: "", template: formTemplateId }
|
? { subject: "", body: "", template: formTemplateId }
|
||||||
|
|
@ -466,7 +501,14 @@ export function MessagingEmailPage() {
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Event</span>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Event</span>
|
||||||
<select style={IS} value={formEventId} onChange={(e) => setFormEventId(e.target.value)}>
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
style={SS}
|
||||||
|
value={formEventId}
|
||||||
|
onChange={(e) => setFormEventId(e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{events.map((ev) => (
|
{events.map((ev) => (
|
||||||
<option key={ev._id} value={ev._id}>
|
<option key={ev._id} value={ev._id}>
|
||||||
|
|
@ -474,15 +516,43 @@ export function MessagingEmailPage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Campaign name</span>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Campaign name</span>
|
||||||
<input style={IS} value={formName} onChange={(e) => setFormName(e.target.value)} placeholder="e.g. Registration reminder" />
|
<input
|
||||||
|
style={IS}
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => setFormName(e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
placeholder="e.g. Registration reminder"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Recipients</span>
|
||||||
|
<RadioGroup
|
||||||
|
name="messaging-email-campaign-recipients"
|
||||||
|
value={recipientSource}
|
||||||
|
onChange={(v) => setRecipientSource(v as RecipientSource)}
|
||||||
|
options={[
|
||||||
|
{ label: "Contact list", value: "participant_list" },
|
||||||
|
{ label: "All participants", value: "participants" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{recipientSource === "participant_list" ? (
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Contact list</span>
|
||||||
<select style={IS} value={formListId} onChange={(e) => setFormListId(e.target.value)}>
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
style={SS}
|
||||||
|
value={formListId}
|
||||||
|
onChange={(e) => setFormListId(e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{lists.map((l) => (
|
{lists.map((l) => (
|
||||||
<option key={l._id} value={l._id}>
|
<option key={l._id} value={l._id}>
|
||||||
|
|
@ -490,7 +560,33 @@ export function MessagingEmailPage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
</label>
|
</label>
|
||||||
|
) : (
|
||||||
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Participant status</span>
|
||||||
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
style={SS}
|
||||||
|
value={formStatus}
|
||||||
|
onChange={(e) => setFormStatus(e.target.value as "" | ParticipantStatus)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{PARTICIPANT_STATUS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||||||
|
Sends to participants registered for the selected event
|
||||||
|
{formStatus ? ` with status ${STATUS_LABELS[formStatus]}` : ""}.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<button type="button" onClick={() => setMsgMode("template")} style={msgMode === "template" ? btnPrimary : btnSecondary}>
|
<button type="button" onClick={() => setMsgMode("template")} style={msgMode === "template" ? btnPrimary : btnSecondary}>
|
||||||
|
|
@ -504,7 +600,14 @@ export function MessagingEmailPage() {
|
||||||
{msgMode === "template" && (
|
{msgMode === "template" && (
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Emailer template</span>
|
||||||
<select style={IS} value={formTemplateId} onChange={(e) => setFormTemplateId(e.target.value)}>
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
style={SS}
|
||||||
|
value={formTemplateId}
|
||||||
|
onChange={(e) => setFormTemplateId(e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
<option value="">Select…</option>
|
<option value="">Select…</option>
|
||||||
{templates.map((t) => (
|
{templates.map((t) => (
|
||||||
<option key={t._id} value={t._id}>
|
<option key={t._id} value={t._id}>
|
||||||
|
|
@ -512,6 +615,7 @@ export function MessagingEmailPage() {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: 0 }}>
|
||||||
Subject and body come from this template. The email subject defaults to your campaign name if the template does not set one.
|
Subject and body come from this template. The email subject defaults to your campaign name if the template does not set one.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -522,7 +626,14 @@ export function MessagingEmailPage() {
|
||||||
<>
|
<>
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Subject</span>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Subject</span>
|
||||||
<input style={IS} value={formSubject} onChange={(e) => setFormSubject(e.target.value)} placeholder="Subject line (placeholders ok)" />
|
<input
|
||||||
|
style={IS}
|
||||||
|
value={formSubject}
|
||||||
|
onChange={(e) => setFormSubject(e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
placeholder="Subject line (placeholders ok)"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -541,7 +652,14 @@ export function MessagingEmailPage() {
|
||||||
|
|
||||||
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Schedule (optional)</span>
|
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500 }}>Schedule (optional)</span>
|
||||||
<input type="datetime-local" style={IS} value={scheduleLocal} onChange={(e) => setScheduleLocal(e.target.value)} />
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
style={IS}
|
||||||
|
value={scheduleLocal}
|
||||||
|
onChange={(e) => setScheduleLocal(e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const EMAIL_PLACEHOLDERS: { token: string; label: string }[] = [
|
||||||
{ token: "{{event.endDate}}", label: "Event end" },
|
{ token: "{{event.endDate}}", label: "Event end" },
|
||||||
{ token: "{{event.venue}}", label: "Venue" },
|
{ token: "{{event.venue}}", label: "Venue" },
|
||||||
{ token: "{{event.virtualLink}}", label: "Virtual link" },
|
{ token: "{{event.virtualLink}}", label: "Virtual link" },
|
||||||
|
{ token: "{{event.logoUrl}}", label: "Event logo URL" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Dropdown options: map each Meta `{{n}}` to a contact/event field for sending (samples still required for Meta review). */
|
/** Dropdown options: map each Meta `{{n}}` to a contact/event field for sending (samples still required for Meta review). */
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,27 @@ import {
|
||||||
HelperText,
|
HelperText,
|
||||||
} from '../panels/PanelShell';
|
} from '../panels/PanelShell';
|
||||||
import {
|
import {
|
||||||
|
eventsApi,
|
||||||
|
formsApi,
|
||||||
sessionsApi,
|
sessionsApi,
|
||||||
staticMicrositeApi,
|
staticMicrositeApi,
|
||||||
staticMicrositePublicUrl,
|
staticMicrositePublicUrl,
|
||||||
|
type Event,
|
||||||
type EventStaticMicrosite,
|
type EventStaticMicrosite,
|
||||||
|
type FormWithSubmissionCount,
|
||||||
type MicrositeV2Template,
|
type MicrositeV2Template,
|
||||||
|
type MicrositeV2TemplateSchema,
|
||||||
type StaticMicrositeConfig,
|
type StaticMicrositeConfig,
|
||||||
} from '../../../services/api';
|
} from '../../../services/api';
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { MicrositeConfigForm } from './MicrositeConfigForm';
|
import { MicrositeConfigForm } from './MicrositeConfigForm';
|
||||||
import { mergeConfig } from './configUtils';
|
import { mergeConfig } from './configUtils';
|
||||||
|
import {
|
||||||
|
applyEventDetailsToMicrositeConfig,
|
||||||
|
buildEventFeedbackUrl,
|
||||||
|
buildEventRegistrationUrl,
|
||||||
|
findContactFormId,
|
||||||
|
} from './eventMicrositeAutofill';
|
||||||
import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
|
import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
|
||||||
|
|
||||||
const SCHEDULE_SECTION_ID = 'event_schedule';
|
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||||
|
|
@ -53,19 +64,40 @@ export function ManageStaticMicrositePanel({
|
||||||
const [scheduleSyncing, setScheduleSyncing] = useState(false);
|
const [scheduleSyncing, setScheduleSyncing] = useState(false);
|
||||||
const scheduleAutoFetchedRef = useRef(false);
|
const scheduleAutoFetchedRef = useRef(false);
|
||||||
const configureOpenedRef = useRef(false);
|
const configureOpenedRef = useRef(false);
|
||||||
|
const [eventDetails, setEventDetails] = useState<Event | null>(null);
|
||||||
|
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
|
||||||
|
|
||||||
|
const withEventAutofill = useCallback(
|
||||||
|
(cfg: StaticMicrositeConfig, sch: MicrositeV2TemplateSchema, event: Event | null, forms: FormWithSubmissionCount[]) => {
|
||||||
|
if (!event) return cfg;
|
||||||
|
return applyEventDetailsToMicrositeConfig(cfg, event, sch, {
|
||||||
|
registrationUrl: buildEventRegistrationUrl(event, forms),
|
||||||
|
feedbackUrl: buildEventFeedbackUrl(event, forms),
|
||||||
|
contactFormId: findContactFormId(forms),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [tplRes, siteRes] = await Promise.all([
|
const [tplRes, siteRes, eventRes, formsRes] = await Promise.all([
|
||||||
staticMicrositeApi.listTemplates(eventId),
|
staticMicrositeApi.listTemplates(eventId),
|
||||||
staticMicrositeApi.getForEvent(eventId),
|
staticMicrositeApi.getForEvent(eventId),
|
||||||
|
eventsApi.get(eventId),
|
||||||
|
formsApi.listForEvent(eventId).catch(() => [] as FormWithSubmissionCount[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (tplRes.error) throw new Error(tplRes.error);
|
if (tplRes.error) throw new Error(tplRes.error);
|
||||||
setTemplates(tplRes.data || []);
|
setTemplates(tplRes.data || []);
|
||||||
|
|
||||||
|
const event = !eventRes.error && eventRes.data ? eventRes.data : null;
|
||||||
|
const forms = formsRes || [];
|
||||||
|
setEventDetails(event);
|
||||||
|
setEventForms(forms);
|
||||||
|
|
||||||
if (!siteRes.error && siteRes.data) {
|
if (!siteRes.error && siteRes.data) {
|
||||||
const site = siteRes.data;
|
const site = siteRes.data;
|
||||||
setExisting(site);
|
setExisting(site);
|
||||||
|
|
@ -86,7 +118,7 @@ export function ManageStaticMicrositePanel({
|
||||||
const sch = detail.data?.schema;
|
const sch = detail.data?.schema;
|
||||||
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
||||||
setSchema(sch);
|
setSchema(sch);
|
||||||
setConfig(mergeConfig(sch, site.config));
|
setConfig(withEventAutofill(mergeConfig(sch, site.config), sch, event, forms));
|
||||||
} finally {
|
} finally {
|
||||||
setSchemaLoading(false);
|
setSchemaLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +129,7 @@ export function ManageStaticMicrositePanel({
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [eventId]);
|
}, [eventId, withEventAutofill]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
|
@ -138,7 +170,7 @@ export function ManageStaticMicrositePanel({
|
||||||
const sch = res.data?.schema;
|
const sch = res.data?.schema;
|
||||||
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
||||||
setSchema(sch);
|
setSchema(sch);
|
||||||
setConfig(mergeConfig(sch, config));
|
setConfig(withEventAutofill(mergeConfig(sch, config), sch, eventDetails, eventForms));
|
||||||
acc.advance(2);
|
acc.advance(2);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Failed to load template configuration');
|
setError(e instanceof Error ? e.message : 'Failed to load template configuration');
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
const SCHEDULE_SECTION_ID = 'event_schedule';
|
|
||||||
import { uploadMediaFile } from '../../../services/api/media';
|
import { uploadMediaFile } from '../../../services/api/media';
|
||||||
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
|
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
|
||||||
import { Field, Label, HelperText } from '../panels/PanelShell';
|
import { Field, Label, HelperText } from '../panels/PanelShell';
|
||||||
|
import { FlatpickrDateInput } from '../ui/FlatpickrDatetimeInput';
|
||||||
import { getNestedValue, setNestedValue } from './configUtils';
|
import { getNestedValue, setNestedValue } from './configUtils';
|
||||||
|
|
||||||
|
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
|
|
@ -18,16 +19,154 @@ const inputStyle: React.CSSProperties = {
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mediaActionsStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
};
|
||||||
|
|
||||||
|
function MediaUploadActions({
|
||||||
|
uploading,
|
||||||
|
hasValue,
|
||||||
|
uploadLabel,
|
||||||
|
changeLabel,
|
||||||
|
onPickFile,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
uploading: boolean;
|
||||||
|
hasValue: boolean;
|
||||||
|
uploadLabel: string;
|
||||||
|
changeLabel: string;
|
||||||
|
onPickFile: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={mediaActionsStyle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={onPickFile}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
cursor: uploading ? 'wait' : 'pointer',
|
||||||
|
width: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : hasValue ? changeLabel : uploadLabel}
|
||||||
|
</button>
|
||||||
|
{hasValue ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={onRemove}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
cursor: uploading ? 'wait' : 'pointer',
|
||||||
|
width: 'auto',
|
||||||
|
color: 'var(--destructive)',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--destructive) 35%, var(--border))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldType(field: MicrositeV2FieldSchema): MicrositeV2FieldType {
|
||||||
|
if (field.type === 'date' || field.type === 'time') return field.type;
|
||||||
|
if (field.key === 'countdown.date') return 'date';
|
||||||
|
if (field.key === 'countdown.time') return 'time';
|
||||||
|
return field.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(value: unknown): string {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||||||
|
const parsed = Date.parse(raw);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
const d = new Date(parsed);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimeValue(value: unknown): { hour: string; minute: string } {
|
||||||
|
const raw = String(value ?? '12:00').trim();
|
||||||
|
const match = raw.match(/^(\d{1,2}):(\d{2})/);
|
||||||
|
if (!match) return { hour: '12', minute: '00' };
|
||||||
|
const hourNum = Math.min(23, Math.max(0, parseInt(match[1], 10) || 0));
|
||||||
|
const minuteNum = Math.min(59, Math.max(0, parseInt(match[2], 10) || 0));
|
||||||
|
return {
|
||||||
|
hour: String(hourNum).padStart(2, '0'),
|
||||||
|
minute: String(minuteNum).padStart(2, '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const { hour, minute } = parseTimeValue(value);
|
||||||
|
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
const selectStyle: React.CSSProperties = { ...inputStyle, width: 'auto', minWidth: '72px' };
|
||||||
|
|
||||||
|
const setTime = (nextHour: string, nextMinute: string) => {
|
||||||
|
onChange(`${nextHour}:${nextMinute}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
value={hour}
|
||||||
|
onChange={(e) => setTime(e.target.value, minute)}
|
||||||
|
style={selectStyle}
|
||||||
|
aria-label="Hour"
|
||||||
|
>
|
||||||
|
{hours.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span style={{ color: 'var(--muted)', fontWeight: 600 }}>:</span>
|
||||||
|
<select
|
||||||
|
value={minute}
|
||||||
|
onChange={(e) => setTime(hour, e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
aria-label="Minute"
|
||||||
|
>
|
||||||
|
{minutes.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FieldInput({
|
function FieldInput({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
eventId,
|
eventId,
|
||||||
|
arrayDepth = 0,
|
||||||
}: {
|
}: {
|
||||||
field: MicrositeV2FieldSchema;
|
field: MicrositeV2FieldSchema;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
arrayDepth?: number;
|
||||||
}) {
|
}) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = React.useState(false);
|
const [uploading, setUploading] = React.useState(false);
|
||||||
|
|
@ -44,7 +183,7 @@ function FieldInput({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (field.type) {
|
switch (resolveFieldType(field)) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return (
|
return (
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)' }}>
|
||||||
|
|
@ -86,6 +225,17 @@ function FieldInput({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<FlatpickrDateInput
|
||||||
|
value={toDateInputValue(value)}
|
||||||
|
onChange={(v) => onChange(v)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder={field.placeholder || 'Select date…'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'time':
|
||||||
|
return <TimeSelect value={value} onChange={onChange} />;
|
||||||
case 'image':
|
case 'image':
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
|
@ -101,19 +251,14 @@ function FieldInput({
|
||||||
if (f) void handleUpload(f);
|
if (f) void handleUpload(f);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}} />
|
}} />
|
||||||
<button
|
<MediaUploadActions
|
||||||
type="button"
|
uploading={uploading}
|
||||||
disabled={uploading}
|
hasValue={Boolean(value)}
|
||||||
onClick={() => fileRef.current?.click()}
|
uploadLabel="Upload image"
|
||||||
style={{
|
changeLabel="Change image"
|
||||||
...inputStyle,
|
onPickFile={() => fileRef.current?.click()}
|
||||||
cursor: uploading ? 'wait' : 'pointer',
|
onRemove={() => onChange('')}
|
||||||
width: 'auto',
|
/>
|
||||||
alignSelf: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{uploading ? 'Uploading…' : value ? 'Change image' : 'Upload image'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'video':
|
case 'video':
|
||||||
|
|
@ -131,19 +276,14 @@ function FieldInput({
|
||||||
if (f) void handleUpload(f);
|
if (f) void handleUpload(f);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}} />
|
}} />
|
||||||
<button
|
<MediaUploadActions
|
||||||
type="button"
|
uploading={uploading}
|
||||||
disabled={uploading}
|
hasValue={Boolean(value)}
|
||||||
onClick={() => fileRef.current?.click()}
|
uploadLabel="Upload video"
|
||||||
style={{
|
changeLabel="Change video"
|
||||||
...inputStyle,
|
onPickFile={() => fileRef.current?.click()}
|
||||||
cursor: uploading ? 'wait' : 'pointer',
|
onRemove={() => onChange('')}
|
||||||
width: 'auto',
|
/>
|
||||||
alignSelf: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'array':
|
case 'array':
|
||||||
|
|
@ -153,6 +293,7 @@ function FieldInput({
|
||||||
value={Array.isArray(value) ? value : []}
|
value={Array.isArray(value) ? value : []}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
depth={arrayDepth}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|
@ -173,18 +314,32 @@ function ArrayFieldEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
eventId,
|
eventId,
|
||||||
|
depth = 0,
|
||||||
}: {
|
}: {
|
||||||
field: MicrositeV2FieldSchema;
|
field: MicrositeV2FieldSchema;
|
||||||
value: Record<string, unknown>[];
|
value: Record<string, unknown>[];
|
||||||
onChange: (v: Record<string, unknown>[]) => void;
|
onChange: (v: Record<string, unknown>[]) => void;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
depth?: number;
|
||||||
}) {
|
}) {
|
||||||
const itemFields = field.itemFields || [];
|
const itemFields = field.itemFields || [];
|
||||||
|
const isTopicsList = field.key === 'topics';
|
||||||
|
const itemLabel = isTopicsList ? 'Topic' : field.label || 'item';
|
||||||
|
const nestedStyle: React.CSSProperties = isTopicsList
|
||||||
|
? {
|
||||||
|
borderLeft: '3px solid color-mix(in srgb, var(--primary) 35%, var(--border))',
|
||||||
|
paddingLeft: '12px',
|
||||||
|
marginTop: '4px',
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
const row: Record<string, unknown> = {};
|
const row: Record<string, unknown> = {};
|
||||||
for (const f of itemFields) {
|
for (const f of itemFields) {
|
||||||
if (f.key) row[f.key] = f.default ?? '';
|
if (f.key) {
|
||||||
|
if (f.type === 'array') row[f.key] = [];
|
||||||
|
else row[f.key] = f.default ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onChange([...value, row]);
|
onChange([...value, row]);
|
||||||
};
|
};
|
||||||
|
|
@ -199,7 +354,7 @@ function ArrayFieldEditor({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', ...nestedStyle }}>
|
||||||
{value.map((row, idx) => (
|
{value.map((row, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
|
|
@ -213,7 +368,9 @@ function ArrayFieldEditor({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>Item {idx + 1}</span>
|
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--muted)' }}>
|
||||||
|
{itemLabel} {idx + 1}
|
||||||
|
</span>
|
||||||
<button type="button" onClick={() => removeRow(idx)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}>
|
<button type="button" onClick={() => removeRow(idx)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--destructive)' }}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -226,6 +383,7 @@ function ArrayFieldEditor({
|
||||||
value={row[f.key]}
|
value={row[f.key]}
|
||||||
onChange={(v) => updateRow(idx, f.key, v)}
|
onChange={(v) => updateRow(idx, f.key, v)}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
arrayDepth={depth + 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -249,7 +407,7 @@ function ArrayFieldEditor({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={14} />
|
<Plus size={14} />
|
||||||
Add {field.label || 'item'}
|
Add {isTopicsList ? 'Session Topic' : field.label || 'item'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -14,11 +14,22 @@ export interface ScheduleSession {
|
||||||
time: string;
|
time: string;
|
||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Session name for block rows with topics */
|
||||||
|
name?: string;
|
||||||
|
/** Nested session topics */
|
||||||
|
topics?: ScheduleTopic[];
|
||||||
/** Agenda session _id for synced rows; absent for manually added rows. */
|
/** Agenda session _id for synced rows; absent for manually added rows. */
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduleTopic {
|
||||||
|
type?: string;
|
||||||
|
duration?: string;
|
||||||
|
title?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduleDay {
|
export interface ScheduleDay {
|
||||||
label: string;
|
label: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
/**
|
/**
|
||||||
* Live badge preview canvas — draggable elements, resize handles, sample data.
|
* Live badge preview canvas — draggable elements, resize handles, sample data.
|
||||||
*/
|
*/
|
||||||
import React, { useCallback, useEffect, useRef } from "react";
|
import React, { useCallback, useEffect, useRef, type RefObject } from "react";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { ImageIcon } from "lucide-react";
|
import { ImageIcon } from "lucide-react";
|
||||||
import type { BadgeLayoutElement } from "./badgeLayout";
|
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
|
||||||
import {
|
import {
|
||||||
|
BADGE_BACKGROUND_IMAGE_ID,
|
||||||
|
applyBackgroundImageLayoutPatch,
|
||||||
applyElementSize,
|
applyElementSize,
|
||||||
clampElementPosition,
|
clampElementPosition,
|
||||||
elementSizePct,
|
elementSizePct,
|
||||||
|
|
@ -39,11 +41,27 @@ type ResizeState = {
|
||||||
origW: number;
|
origW: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BgDragState = {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
origX: number;
|
||||||
|
origY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BgResizeState = {
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
origX: number;
|
||||||
|
origY: number;
|
||||||
|
origW: number;
|
||||||
|
origH: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function BadgeDesignerPreview({
|
export function BadgeDesignerPreview({
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
backgroundMode,
|
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
backgroundImageUrl,
|
backgroundImageUrl,
|
||||||
|
backgroundImageLayout,
|
||||||
layoutElements,
|
layoutElements,
|
||||||
customImages,
|
customImages,
|
||||||
badgeTypeLabel,
|
badgeTypeLabel,
|
||||||
|
|
@ -51,31 +69,44 @@ export function BadgeDesignerPreview({
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
|
onBackgroundImageLayoutChange,
|
||||||
|
surfaceRef,
|
||||||
}: {
|
}: {
|
||||||
aspectRatio: number;
|
aspectRatio: number;
|
||||||
badgeAspectRatio: number;
|
badgeAspectRatio: number;
|
||||||
backgroundMode: "image" | "color";
|
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
backgroundImageUrl: string;
|
backgroundImageUrl: string;
|
||||||
|
backgroundImageLayout: BadgeBackgroundImageLayout;
|
||||||
layoutElements: BadgeLayoutElement[];
|
layoutElements: BadgeLayoutElement[];
|
||||||
customImages: { id: string; imageUrl: string }[];
|
customImages: { id: string; imageUrl: string }[];
|
||||||
badgeTypeLabel: string;
|
badgeTypeLabel: string;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
onLayoutChange: (next: BadgeLayoutElement[]) => void;
|
onLayoutChange: (next: BadgeLayoutElement[]) => void;
|
||||||
|
onBackgroundImageLayoutChange: (next: BadgeBackgroundImageLayout) => void;
|
||||||
|
/** Canvas + bg-image handles — clicks outside this (and the inspector) dismiss selection */
|
||||||
|
surfaceRef?: RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const layoutRef = useRef(layoutElements);
|
const layoutRef = useRef(layoutElements);
|
||||||
|
const bgLayoutRef = useRef(backgroundImageLayout);
|
||||||
const onLayoutChangeRef = useRef(onLayoutChange);
|
const onLayoutChangeRef = useRef(onLayoutChange);
|
||||||
|
const onBgLayoutChangeRef = useRef(onBackgroundImageLayoutChange);
|
||||||
const dragRef = useRef<DragState | null>(null);
|
const dragRef = useRef<DragState | null>(null);
|
||||||
const resizeRef = useRef<ResizeState | null>(null);
|
const resizeRef = useRef<ResizeState | null>(null);
|
||||||
|
const bgDragRef = useRef<BgDragState | null>(null);
|
||||||
|
const bgResizeRef = useRef<BgResizeState | null>(null);
|
||||||
const qrUrlRef = useRef("");
|
const qrUrlRef = useRef("");
|
||||||
const [, setTick] = React.useState(0);
|
const [, setTick] = React.useState(0);
|
||||||
|
|
||||||
layoutRef.current = layoutElements;
|
layoutRef.current = layoutElements;
|
||||||
|
bgLayoutRef.current = backgroundImageLayout;
|
||||||
onLayoutChangeRef.current = onLayoutChange;
|
onLayoutChangeRef.current = onLayoutChange;
|
||||||
|
onBgLayoutChangeRef.current = onBackgroundImageLayoutChange;
|
||||||
|
|
||||||
const sample = samplePreviewData(badgeTypeLabel);
|
const sample = samplePreviewData(badgeTypeLabel);
|
||||||
|
const hasBgImage = Boolean(backgroundImageUrl.trim());
|
||||||
|
const bgSelected = selectedId === BADGE_BACKGROUND_IMAGE_ID;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
|
QRCode.toDataURL("eventbuzz:preview:sample", { width: 120, margin: 1 })
|
||||||
|
|
@ -99,6 +130,36 @@ export function BadgeDesignerPreview({
|
||||||
const box = canvasRef.current?.getBoundingClientRect();
|
const box = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!box?.width || !box.height) return;
|
if (!box?.width || !box.height) return;
|
||||||
|
|
||||||
|
const bgDrag = bgDragRef.current;
|
||||||
|
if (bgDrag) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = ((e.clientX - bgDrag.startX) / box.width) * 100;
|
||||||
|
const dy = ((e.clientY - bgDrag.startY) / box.height) * 100;
|
||||||
|
const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
|
||||||
|
x: bgDrag.origX + dx,
|
||||||
|
y: bgDrag.origY + dy,
|
||||||
|
});
|
||||||
|
bgLayoutRef.current = next;
|
||||||
|
onBgLayoutChangeRef.current(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgResize = bgResizeRef.current;
|
||||||
|
if (bgResize) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = ((e.clientX - bgResize.startX) / box.width) * 100;
|
||||||
|
const dy = ((e.clientY - bgResize.startY) / box.height) * 100;
|
||||||
|
const next = applyBackgroundImageLayoutPatch(bgLayoutRef.current, {
|
||||||
|
x: bgResize.origX,
|
||||||
|
y: bgResize.origY,
|
||||||
|
w: bgResize.origW + dx,
|
||||||
|
h: bgResize.origH + dy,
|
||||||
|
});
|
||||||
|
bgLayoutRef.current = next;
|
||||||
|
onBgLayoutChangeRef.current(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (drag) {
|
if (drag) {
|
||||||
const el = layoutRef.current.find((x) => x.id === drag.id);
|
const el = layoutRef.current.find((x) => x.id === drag.id);
|
||||||
|
|
@ -106,7 +167,7 @@ export function BadgeDesignerPreview({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const dx = ((e.clientX - drag.startX) / box.width) * 100;
|
const dx = ((e.clientX - drag.startX) / box.width) * 100;
|
||||||
const dy = ((e.clientY - drag.startY) / box.height) * 100;
|
const dy = ((e.clientY - drag.startY) / box.height) * 100;
|
||||||
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy);
|
const pos = clampElementPosition(el, drag.origX + dx, drag.origY + dy, badgeAspectRatio);
|
||||||
patchElement(drag.id, pos);
|
patchElement(drag.id, pos);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -126,6 +187,8 @@ export function BadgeDesignerPreview({
|
||||||
const end = () => {
|
const end = () => {
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
resizeRef.current = null;
|
resizeRef.current = null;
|
||||||
|
bgDragRef.current = null;
|
||||||
|
bgResizeRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", onMove, { passive: false });
|
window.addEventListener("pointermove", onMove, { passive: false });
|
||||||
|
|
@ -136,10 +199,10 @@ export function BadgeDesignerPreview({
|
||||||
window.removeEventListener("pointerup", end);
|
window.removeEventListener("pointerup", end);
|
||||||
window.removeEventListener("pointercancel", end);
|
window.removeEventListener("pointercancel", end);
|
||||||
};
|
};
|
||||||
}, [patchElement]);
|
}, [patchElement, badgeAspectRatio]);
|
||||||
|
|
||||||
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
|
const startDrag = (e: React.PointerEvent, el: BadgeLayoutElement) => {
|
||||||
if (resizeRef.current) return;
|
if (resizeRef.current || bgDragRef.current || bgResizeRef.current) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(el.id);
|
onSelect(el.id);
|
||||||
|
|
@ -165,27 +228,56 @@ export function BadgeDesignerPreview({
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const bgStyle: React.CSSProperties =
|
const startBgDrag = (e: React.PointerEvent) => {
|
||||||
backgroundMode === "image" && backgroundImageUrl.trim()
|
if (bgResizeRef.current || resizeRef.current) return;
|
||||||
? {
|
e.preventDefault();
|
||||||
backgroundImage: `url(${backgroundImageUrl})`,
|
e.stopPropagation();
|
||||||
backgroundSize: "cover",
|
onSelect(BADGE_BACKGROUND_IMAGE_ID);
|
||||||
backgroundPosition: "center",
|
bgDragRef.current = {
|
||||||
}
|
startX: e.clientX,
|
||||||
: { backgroundColor };
|
startY: e.clientY,
|
||||||
|
origX: backgroundImageLayout.x,
|
||||||
|
origY: backgroundImageLayout.y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const startBgResize = (e: React.PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(BADGE_BACKGROUND_IMAGE_ID);
|
||||||
|
bgDragRef.current = null;
|
||||||
|
bgResizeRef.current = {
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
origX: backgroundImageLayout.x,
|
||||||
|
origY: backgroundImageLayout.y,
|
||||||
|
origW: backgroundImageLayout.w,
|
||||||
|
origH: backgroundImageLayout.h,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "8px 12px 16px 20px" }}>
|
<div style={{ padding: "8px 12px 16px 20px" }}>
|
||||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 10px" }}>
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 10px" }}>
|
||||||
Live preview — drag to move; drag corner handle to resize
|
Live preview — drag fields to move; drag corner handles to resize
|
||||||
|
{hasBgImage
|
||||||
|
? "; drag the background image to pan (it may extend outside the badge), resize to zoom"
|
||||||
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
ref={surfaceRef}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onClick={() => onSelect(null)}
|
onClick={() => onSelect(null)}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: 420,
|
|
||||||
aspectRatio: String(aspectRatio),
|
aspectRatio: String(aspectRatio),
|
||||||
position: "relative",
|
position: "relative",
|
||||||
borderRadius: "var(--radius-button)",
|
borderRadius: "var(--radius-button)",
|
||||||
|
|
@ -194,11 +286,45 @@ export function BadgeDesignerPreview({
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
containerType: "size",
|
containerType: "size",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
...bgStyle,
|
backgroundColor,
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
userSelect: "none",
|
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) => (
|
{layoutElements.map((el) => (
|
||||||
<PreviewElement
|
<PreviewElement
|
||||||
key={el.id}
|
key={el.id}
|
||||||
|
|
@ -212,6 +338,42 @@ export function BadgeDesignerPreview({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{hasBgImage && bgSelected ? (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: `${backgroundImageLayout.x}%`,
|
||||||
|
top: `${backgroundImageLayout.y}%`,
|
||||||
|
width: `${backgroundImageLayout.w}%`,
|
||||||
|
height: `${backgroundImageLayout.h}%`,
|
||||||
|
border: "2px solid var(--primary)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
onPointerDown={startBgResize}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: -5,
|
||||||
|
bottom: -5,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "var(--primary)",
|
||||||
|
border: "2px solid var(--card)",
|
||||||
|
cursor: "nwse-resize",
|
||||||
|
touchAction: "none",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +414,7 @@ function PreviewElement({
|
||||||
cursor: "grab",
|
cursor: "grab",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
|
zIndex: 1,
|
||||||
...extra,
|
...extra,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -299,6 +462,7 @@ function PreviewElement({
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{url ? (
|
{url ? (
|
||||||
|
|
@ -481,6 +645,7 @@ function TextPreviewBlock({
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
textAlign: align,
|
textAlign: align,
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function BadgeFieldInspector({
|
||||||
customTextValue,
|
customTextValue,
|
||||||
customImages,
|
customImages,
|
||||||
badgeAspectRatio,
|
badgeAspectRatio,
|
||||||
|
mediaEventId = "portfolio",
|
||||||
onChange,
|
onChange,
|
||||||
onRemoveCustom,
|
onRemoveCustom,
|
||||||
onCustomImageUploaded,
|
onCustomImageUploaded,
|
||||||
|
|
@ -53,6 +54,7 @@ export function BadgeFieldInspector({
|
||||||
customTextValue?: string;
|
customTextValue?: string;
|
||||||
customImages: { id: string; imageUrl: string }[];
|
customImages: { id: string; imageUrl: string }[];
|
||||||
badgeAspectRatio: number;
|
badgeAspectRatio: number;
|
||||||
|
mediaEventId?: string;
|
||||||
onChange: (patch: Partial<BadgeLayoutElement>) => void;
|
onChange: (patch: Partial<BadgeLayoutElement>) => void;
|
||||||
onRemoveCustom?: () => void;
|
onRemoveCustom?: () => void;
|
||||||
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
|
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
|
||||||
|
|
@ -76,6 +78,7 @@ export function BadgeFieldInspector({
|
||||||
element={element}
|
element={element}
|
||||||
customImages={customImages}
|
customImages={customImages}
|
||||||
badgeAspectRatio={badgeAspectRatio}
|
badgeAspectRatio={badgeAspectRatio}
|
||||||
|
mediaEventId={mediaEventId}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onRemoveCustom={onRemoveCustom}
|
onRemoveCustom={onRemoveCustom}
|
||||||
onCustomImageUploaded={onCustomImageUploaded}
|
onCustomImageUploaded={onCustomImageUploaded}
|
||||||
|
|
@ -224,6 +227,7 @@ function ResizableInspector({
|
||||||
element,
|
element,
|
||||||
customImages,
|
customImages,
|
||||||
badgeAspectRatio,
|
badgeAspectRatio,
|
||||||
|
mediaEventId = "portfolio",
|
||||||
onChange,
|
onChange,
|
||||||
onRemoveCustom,
|
onRemoveCustom,
|
||||||
onCustomImageUploaded,
|
onCustomImageUploaded,
|
||||||
|
|
@ -231,6 +235,7 @@ function ResizableInspector({
|
||||||
element: BadgeLayoutElement;
|
element: BadgeLayoutElement;
|
||||||
customImages: { id: string; imageUrl: string }[];
|
customImages: { id: string; imageUrl: string }[];
|
||||||
badgeAspectRatio: number;
|
badgeAspectRatio: number;
|
||||||
|
mediaEventId?: string;
|
||||||
onChange: (patch: Partial<BadgeLayoutElement>) => void;
|
onChange: (patch: Partial<BadgeLayoutElement>) => void;
|
||||||
onRemoveCustom?: () => void;
|
onRemoveCustom?: () => void;
|
||||||
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
|
onCustomImageUploaded?: (id: string, url: string, imageAspectRatio: number) => void;
|
||||||
|
|
@ -250,7 +255,7 @@ function ResizableInspector({
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const imageAspectRatio = await readFileImageAspectRatio(file);
|
const imageAspectRatio = await readFileImageAspectRatio(file);
|
||||||
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
|
const r = await uploadMediaFile(file, { eventId: mediaEventId, module: "badges/fixed-images" });
|
||||||
if (r.url) {
|
if (r.url) {
|
||||||
const w = element.w ?? 25;
|
const w = element.w ?? 25;
|
||||||
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
|
const h = heightPctForImageWidth(w, imageAspectRatio, badgeAspectRatio);
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,21 @@ import {
|
||||||
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
|
import type { Event as ApiEvent, ParticipantListRecord } from "../../../services/api";
|
||||||
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
|
import { BadgeDesignerPreview, useBadgeAspectRatio } from "./BadgeDesignerPreview";
|
||||||
import { BadgeFieldInspector } from "./BadgeFieldInspector";
|
import { BadgeFieldInspector } from "./BadgeFieldInspector";
|
||||||
import type { BadgeLayoutElement } from "./badgeLayout";
|
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
|
||||||
import {
|
import {
|
||||||
|
BADGE_BACKGROUND_IMAGE_ID,
|
||||||
MAX_CUSTOM_IMAGE_FIELDS,
|
MAX_CUSTOM_IMAGE_FIELDS,
|
||||||
MAX_CUSTOM_TEXT_FIELDS,
|
MAX_CUSTOM_TEXT_FIELDS,
|
||||||
applyElementSize,
|
applyElementSize,
|
||||||
buildCustomImageElement,
|
buildCustomImageElement,
|
||||||
clampElementPosition,
|
clampElementPosition,
|
||||||
|
defaultBackgroundImageLayout,
|
||||||
defaultLayoutForFields,
|
defaultLayoutForFields,
|
||||||
heightPctForImageWidth,
|
heightPctForImageWidth,
|
||||||
newCustomImageId,
|
newCustomImageId,
|
||||||
newCustomTextId,
|
newCustomTextId,
|
||||||
readFileImageAspectRatio,
|
readFileImageAspectRatio,
|
||||||
|
squareElementHeightPct,
|
||||||
syncLayoutWithFields,
|
syncLayoutWithFields,
|
||||||
} from "./badgeLayout";
|
} from "./badgeLayout";
|
||||||
import { formStateFromBadgeBatch } from "./badgeBatchFormState";
|
import { formStateFromBadgeBatch } from "./badgeBatchFormState";
|
||||||
|
|
@ -179,9 +182,11 @@ export function CreateBadgePanel({
|
||||||
const [includeParticipants, setIncludeParticipants] = useState(true);
|
const [includeParticipants, setIncludeParticipants] = useState(true);
|
||||||
const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all");
|
const [participantScope, setParticipantScope] = useState<"all" | "participant_list">("all");
|
||||||
const [participantListId, setParticipantListId] = useState("");
|
const [participantListId, setParticipantListId] = useState("");
|
||||||
const [backgroundMode, setBackgroundMode] = useState<"image" | "color">("color");
|
|
||||||
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
|
const [backgroundColor, setBackgroundColor] = useState("#0B4DD1");
|
||||||
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
|
const [backgroundImageUrl, setBackgroundImageUrl] = useState("");
|
||||||
|
const [backgroundImageLayout, setBackgroundImageLayout] = useState<BadgeBackgroundImageLayout>(
|
||||||
|
defaultBackgroundImageLayout,
|
||||||
|
);
|
||||||
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
|
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState("");
|
||||||
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
|
const [enabledFields, setEnabledFields] = useState<string[]>(["name", "title", "org", "qr"]);
|
||||||
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
|
const [layoutElements, setLayoutElements] = useState<BadgeLayoutElement[]>(() =>
|
||||||
|
|
@ -199,6 +204,9 @@ export function CreateBadgePanel({
|
||||||
const [headerBusy, setHeaderBusy] = useState(false);
|
const [headerBusy, setHeaderBusy] = useState(false);
|
||||||
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
|
const [hydrating, setHydrating] = useState(Boolean(duplicateFromBatchId));
|
||||||
|
|
||||||
|
const badgeSurfaceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const badgeInspectorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
|
const aspectRatio = useBadgeAspectRatio(sizePreset, orientation, customW, customH);
|
||||||
const isDuplicate = Boolean(duplicateFromBatchId);
|
const isDuplicate = Boolean(duplicateFromBatchId);
|
||||||
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
|
const displayBgUrl = backgroundPreviewUrl || backgroundImageUrl;
|
||||||
|
|
@ -208,6 +216,18 @@ export function CreateBadgePanel({
|
||||||
[layoutElements, selectedElementId],
|
[layoutElements, selectedElementId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedElementId) return;
|
||||||
|
const dismissSelection = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (badgeSurfaceRef.current?.contains(target)) return;
|
||||||
|
if (badgeInspectorRef.current?.contains(target)) return;
|
||||||
|
setSelectedElementId(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", dismissSelection);
|
||||||
|
return () => document.removeEventListener("pointerdown", dismissSelection);
|
||||||
|
}, [selectedElementId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
|
if (backgroundPreviewUrl.startsWith("blob:")) URL.revokeObjectURL(backgroundPreviewUrl);
|
||||||
|
|
@ -220,14 +240,14 @@ export function CreateBadgePanel({
|
||||||
let merged = { ...el };
|
let merged = { ...el };
|
||||||
if (el.field === "qr") {
|
if (el.field === "qr") {
|
||||||
const w = el.w ?? 28;
|
const w = el.w ?? 28;
|
||||||
merged = { ...merged, w, h: w };
|
merged = { ...merged, w, h: squareElementHeightPct(w, aspectRatio) };
|
||||||
} else if (el.field === "photo" && el.w != null) {
|
} else if (el.field === "photo" && el.w != null) {
|
||||||
merged = { ...merged, h: el.w };
|
merged = { ...merged, h: squareElementHeightPct(el.w, aspectRatio) };
|
||||||
} else if (el.kind === "custom_image" && el.imageAspectRatio) {
|
} else if (el.kind === "custom_image" && el.imageAspectRatio) {
|
||||||
const w = el.w ?? 25;
|
const w = el.w ?? 25;
|
||||||
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
|
merged = { ...merged, h: heightPctForImageWidth(w, el.imageAspectRatio, aspectRatio) };
|
||||||
}
|
}
|
||||||
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y) };
|
return { ...merged, ...clampElementPosition(merged, merged.x, merged.y, aspectRatio) };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [sizePreset, orientation, customW, customH, aspectRatio]);
|
}, [sizePreset, orientation, customW, customH, aspectRatio]);
|
||||||
|
|
@ -258,9 +278,9 @@ export function CreateBadgePanel({
|
||||||
setIncludeParticipants(s.includeParticipants);
|
setIncludeParticipants(s.includeParticipants);
|
||||||
setParticipantScope(s.participantScope);
|
setParticipantScope(s.participantScope);
|
||||||
setParticipantListId(s.participantListId);
|
setParticipantListId(s.participantListId);
|
||||||
setBackgroundMode(s.backgroundMode);
|
|
||||||
setBackgroundColor(s.backgroundColor);
|
setBackgroundColor(s.backgroundColor);
|
||||||
setBackgroundImageUrl(s.backgroundImageUrl);
|
setBackgroundImageUrl(s.backgroundImageUrl);
|
||||||
|
setBackgroundImageLayout(s.backgroundImageLayout);
|
||||||
setBackgroundPreviewUrl("");
|
setBackgroundPreviewUrl("");
|
||||||
setEnabledFields(s.enabledFields);
|
setEnabledFields(s.enabledFields);
|
||||||
setLayoutElements(s.layoutElements);
|
setLayoutElements(s.layoutElements);
|
||||||
|
|
@ -302,7 +322,7 @@ export function CreateBadgePanel({
|
||||||
: [...enabledFields, id];
|
: [...enabledFields, id];
|
||||||
setEnabledFields(next);
|
setEnabledFields(next);
|
||||||
setLayoutElements((prev) =>
|
setLayoutElements((prev) =>
|
||||||
syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, backgroundMode, aspectRatio),
|
syncLayoutWithFields(prev, next, customTexts, customImages, backgroundColor, "color", aspectRatio),
|
||||||
);
|
);
|
||||||
if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null);
|
if (!next.includes(id) && selectedElementId === id) setSelectedElementId(null);
|
||||||
};
|
};
|
||||||
|
|
@ -314,11 +334,13 @@ export function CreateBadgePanel({
|
||||||
const nextCustom = [...customTexts, { id, text }];
|
const nextCustom = [...customTexts, { id, text }];
|
||||||
setCustomTexts(nextCustom);
|
setCustomTexts(nextCustom);
|
||||||
setLayoutElements((prev) =>
|
setLayoutElements((prev) =>
|
||||||
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, backgroundMode, aspectRatio),
|
syncLayoutWithFields(prev, enabledFields, nextCustom, customImages, backgroundColor, "color", aspectRatio),
|
||||||
);
|
);
|
||||||
setSelectedElementId(id);
|
setSelectedElementId(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const badgeMediaEventId = formEventId.trim() || "portfolio";
|
||||||
|
|
||||||
const addCustomImage = async (file: File) => {
|
const addCustomImage = async (file: File) => {
|
||||||
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
|
if (customImages.length >= MAX_CUSTOM_IMAGE_FIELDS) return;
|
||||||
if (!file.type.startsWith("image/")) {
|
if (!file.type.startsWith("image/")) {
|
||||||
|
|
@ -330,7 +352,7 @@ export function CreateBadgePanel({
|
||||||
setErr("");
|
setErr("");
|
||||||
try {
|
try {
|
||||||
const imageAspectRatio = await readFileImageAspectRatio(file);
|
const imageAspectRatio = await readFileImageAspectRatio(file);
|
||||||
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/fixed-images" });
|
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/fixed-images" });
|
||||||
if (!r.url) throw new Error("Upload failed");
|
if (!r.url) throw new Error("Upload failed");
|
||||||
const nextImages = [...customImages, { id, imageUrl: r.url }];
|
const nextImages = [...customImages, { id, imageUrl: r.url }];
|
||||||
setCustomImages(nextImages);
|
setCustomImages(nextImages);
|
||||||
|
|
@ -398,11 +420,13 @@ export function CreateBadgePanel({
|
||||||
setBackgroundPreviewUrl(local);
|
setBackgroundPreviewUrl(local);
|
||||||
setHeaderBusy(true);
|
setHeaderBusy(true);
|
||||||
setErr("");
|
setErr("");
|
||||||
|
const hadBgImage = Boolean(backgroundImageUrl.trim());
|
||||||
try {
|
try {
|
||||||
const r = await uploadMediaFile(file, { eventId: "portfolio", module: "badges/backgrounds" });
|
const r = await uploadMediaFile(file, { eventId: badgeMediaEventId, module: "badges/backgrounds" });
|
||||||
if (r.url) {
|
if (r.url) {
|
||||||
setBackgroundImageUrl(r.url);
|
setBackgroundImageUrl(r.url);
|
||||||
setBackgroundMode("image");
|
if (!hadBgImage) setBackgroundImageLayout(defaultBackgroundImageLayout());
|
||||||
|
setSelectedElementId(BADGE_BACKGROUND_IMAGE_ID);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : "Upload failed");
|
setErr(e instanceof Error ? e.message : "Upload failed");
|
||||||
|
|
@ -434,10 +458,6 @@ export function CreateBadgePanel({
|
||||||
setErr("Select a contact list.");
|
setErr("Select a contact list.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (backgroundMode === "image" && !backgroundImageUrl.trim()) {
|
|
||||||
setErr("Upload a background image or switch to solid colour.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCustom =
|
const hasCustom =
|
||||||
layoutElements.some((el) => el.kind === "custom_text") ||
|
layoutElements.some((el) => el.kind === "custom_text") ||
|
||||||
layoutElements.some((el) => el.kind === "custom_image");
|
layoutElements.some((el) => el.kind === "custom_image");
|
||||||
|
|
@ -495,8 +515,9 @@ export function CreateBadgePanel({
|
||||||
includeParticipants,
|
includeParticipants,
|
||||||
participantScope,
|
participantScope,
|
||||||
...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}),
|
...(includeParticipants && participantScope === "participant_list" ? { participantListId } : {}),
|
||||||
backgroundMode,
|
backgroundMode: backgroundImageUrl.trim() ? "image" : "color",
|
||||||
backgroundImageUrl: backgroundMode === "image" ? backgroundImageUrl.trim() : "",
|
backgroundImageUrl: backgroundImageUrl.trim(),
|
||||||
|
...(backgroundImageUrl.trim() ? { backgroundImageLayout } : {}),
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
fields: enabledFields,
|
fields: enabledFields,
|
||||||
layout: layoutPayload,
|
layout: layoutPayload,
|
||||||
|
|
@ -548,21 +569,25 @@ export function CreateBadgePanel({
|
||||||
<BadgeDesignerPreview
|
<BadgeDesignerPreview
|
||||||
aspectRatio={aspectRatio}
|
aspectRatio={aspectRatio}
|
||||||
badgeAspectRatio={aspectRatio}
|
badgeAspectRatio={aspectRatio}
|
||||||
backgroundMode={backgroundMode}
|
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
backgroundImageUrl={displayBgUrl}
|
backgroundImageUrl={displayBgUrl}
|
||||||
|
backgroundImageLayout={backgroundImageLayout}
|
||||||
layoutElements={layoutElements}
|
layoutElements={layoutElements}
|
||||||
customImages={customImages}
|
customImages={customImages}
|
||||||
badgeTypeLabel={badgeTypeLabel}
|
badgeTypeLabel={badgeTypeLabel}
|
||||||
selectedId={selectedElementId}
|
selectedId={selectedElementId}
|
||||||
onSelect={setSelectedElementId}
|
onSelect={setSelectedElementId}
|
||||||
onLayoutChange={setLayoutElements}
|
onLayoutChange={setLayoutElements}
|
||||||
|
onBackgroundImageLayoutChange={setBackgroundImageLayout}
|
||||||
|
surfaceRef={badgeSurfaceRef}
|
||||||
/>
|
/>
|
||||||
|
<div ref={badgeInspectorRef}>
|
||||||
<BadgeFieldInspector
|
<BadgeFieldInspector
|
||||||
element={selectedElement}
|
element={selectedElementId === BADGE_BACKGROUND_IMAGE_ID ? null : selectedElement}
|
||||||
customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text}
|
customTextValue={customTexts.find((c) => c.id === selectedElementId)?.text}
|
||||||
customImages={customImages}
|
customImages={customImages}
|
||||||
badgeAspectRatio={aspectRatio}
|
badgeAspectRatio={aspectRatio}
|
||||||
|
mediaEventId={badgeMediaEventId}
|
||||||
onChange={updateSelectedElement}
|
onChange={updateSelectedElement}
|
||||||
onRemoveCustom={
|
onRemoveCustom={
|
||||||
selectedElement?.kind === "custom_text"
|
selectedElement?.kind === "custom_text"
|
||||||
|
|
@ -578,6 +603,7 @@ export function CreateBadgePanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px" }}>{err}</p>}
|
{err && <p style={{ color: "var(--destructive)", fontSize: "var(--text-sm)", margin: "0 16px 0 20px" }}>{err}</p>}
|
||||||
|
|
@ -722,34 +748,53 @@ export function CreateBadgePanel({
|
||||||
nextLabel="Output"
|
nextLabel="Output"
|
||||||
>
|
>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Background" required />
|
<Label text="Background colour" required />
|
||||||
<RadioGroup
|
|
||||||
name="bgMode"
|
|
||||||
value={backgroundMode}
|
|
||||||
onChange={(v) => setBackgroundMode(v as "image" | "color")}
|
|
||||||
options={[
|
|
||||||
{ label: "Solid colour", value: "color" },
|
|
||||||
{ label: "Image", value: "image" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{backgroundMode === "color" && (
|
|
||||||
<Field>
|
|
||||||
<Label text="Colour" />
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<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 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} />
|
<input style={{ ...IS, flex: 1 }} value={backgroundColor} onChange={(e) => setBackgroundColor(e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||||
</div>
|
</div>
|
||||||
|
<HelperText text="Always applied behind badge fields. Shows through transparent areas of the background image." />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
|
||||||
{backgroundMode === "image" && (
|
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Background image" />
|
<Label text="Background image (optional)" />
|
||||||
<input style={IS} value={backgroundImageUrl} onChange={(e) => setBackgroundImageUrl(e.target.value)} placeholder="https://… or upload" onFocus={focusIn} onBlur={focusOut} />
|
<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)} />
|
<BackgroundUploadButton busy={headerBusy} onFile={(f) => void onHeaderFile(f)} />
|
||||||
<HelperText text="Must be reachable by the server when the worker runs (public HTTPS)." />
|
{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>
|
||||||
)}
|
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Badge fields" />
|
<Label text="Badge fields" />
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const STATUS_OPTIONS: { label: string; value: string }[] = [
|
||||||
{ label: "Confirmed", value: "confirmed" },
|
{ label: "Confirmed", value: "confirmed" },
|
||||||
{ label: "Checked In", value: "checked_in" },
|
{ label: "Checked In", value: "checked_in" },
|
||||||
{ label: "Cancelled", value: "cancelled" },
|
{ label: "Cancelled", value: "cancelled" },
|
||||||
|
{ label: "Rejected", value: "rejected" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyForm = (): Form => ({
|
const emptyForm = (): Form => ({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ type LinkKind = "form" | "microsite" | "custom";
|
||||||
|
|
||||||
export type EmailerContentBlock =
|
export type EmailerContentBlock =
|
||||||
| { id: string; type: "text"; html: string }
|
| { id: string; type: "text"; html: string }
|
||||||
| { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; micrositeId?: string; customUrl?: string }
|
| { id: string; type: "cta"; label: string; linkKind: LinkKind; formId?: string; formType?: string; micrositeId?: string; customUrl?: string }
|
||||||
| { id: string; type: "image"; src: string }
|
| { id: string; type: "image"; src: string }
|
||||||
| { id: string; type: "divider" };
|
| { id: string; type: "divider" };
|
||||||
|
|
||||||
|
|
@ -99,17 +99,25 @@ function publicFormHref(formId: string, eventId?: string): string {
|
||||||
|
|
||||||
function resolveCtaHref(
|
function resolveCtaHref(
|
||||||
b: Extract<EmailerContentBlock, { type: "cta" }>,
|
b: Extract<EmailerContentBlock, { type: "cta" }>,
|
||||||
targets: { forms: { id: string; href: string }[]; microsites: { id: string; href: string }[] },
|
targets: { forms: { id: string; href: string; type?: string }[]; microsites: { id: string; href: string }[] },
|
||||||
): string {
|
): string {
|
||||||
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
|
if (b.linkKind === "custom") return String(b.customUrl || "").trim() || "#";
|
||||||
if (b.linkKind === "form") {
|
if (b.linkKind === "form") {
|
||||||
|
if (b.formId) {
|
||||||
const f = targets.forms.find((x) => x.id === b.formId);
|
const f = targets.forms.find((x) => x.id === b.formId);
|
||||||
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
|
const href = (f?.href && f.href.trim()) || (b.formId ? publicFormHref(b.formId) : "");
|
||||||
return href || "#";
|
if (href) return href;
|
||||||
|
}
|
||||||
|
if (b.formType) {
|
||||||
|
const f = targets.forms.find((x) => x.type === b.formType);
|
||||||
|
if (f?.href?.trim()) return f.href.trim();
|
||||||
|
}
|
||||||
|
return "#";
|
||||||
}
|
}
|
||||||
if (b.linkKind === "microsite") {
|
if (b.linkKind === "microsite") {
|
||||||
const m = targets.microsites.find((x) => x.id === b.micrositeId);
|
const m = targets.microsites.find((x) => x.id === b.micrositeId);
|
||||||
return (m?.href && m.href.trim()) || "#";
|
if (m?.href?.trim()) return m.href.trim();
|
||||||
|
return targets.microsites[0]?.href?.trim() || "#";
|
||||||
}
|
}
|
||||||
return "#";
|
return "#";
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +203,7 @@ function migrateBlocksFromContent(c: Record<string, unknown>): EmailerContentBlo
|
||||||
label: String(b.label ?? b.text ?? "Register"),
|
label: String(b.label ?? b.text ?? "Register"),
|
||||||
linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind,
|
linkKind: (["form", "microsite", "custom"].includes(String(b.linkKind)) ? b.linkKind : "custom") as LinkKind,
|
||||||
formId: typeof b.formId === "string" ? b.formId : undefined,
|
formId: typeof b.formId === "string" ? b.formId : undefined,
|
||||||
|
formType: typeof b.formType === "string" ? b.formType : undefined,
|
||||||
micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined,
|
micrositeId: typeof b.micrositeId === "string" ? b.micrositeId : undefined,
|
||||||
customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""),
|
customUrl: String(b.customUrl ?? legacyCtaUrl ?? ""),
|
||||||
});
|
});
|
||||||
|
|
@ -250,6 +259,7 @@ export function CreateEmailerPanel({
|
||||||
const [campaignEventId, setCampaignEventId] = useState("");
|
const [campaignEventId, setCampaignEventId] = useState("");
|
||||||
const [participantListId, setParticipantListId] = useState("");
|
const [participantListId, setParticipantListId] = useState("");
|
||||||
const [campaignErr, setCampaignErr] = useState("");
|
const [campaignErr, setCampaignErr] = useState("");
|
||||||
|
const [editingGlobalTemplate, setEditingGlobalTemplate] = useState(false);
|
||||||
|
|
||||||
const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => {
|
const set = useCallback(<K extends keyof EForm>(k: K, v: EForm[K]) => {
|
||||||
setForm((p) => ({ ...p, [k]: v }));
|
setForm((p) => ({ ...p, [k]: v }));
|
||||||
|
|
@ -285,6 +295,7 @@ export function CreateEmailerPanel({
|
||||||
void designTemplatesApi.get(templateId).then((res) => {
|
void designTemplatesApi.get(templateId).then((res) => {
|
||||||
if (!res.data) return;
|
if (!res.data) return;
|
||||||
const t = res.data;
|
const t = res.data;
|
||||||
|
setEditingGlobalTemplate(Boolean(t.isSystemTemplate));
|
||||||
const c = (t.content || {}) as Record<string, unknown>;
|
const c = (t.content || {}) as Record<string, unknown>;
|
||||||
const migrated = migrateBlocksFromContent(c);
|
const migrated = migrateBlocksFromContent(c);
|
||||||
const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent;
|
const rawAssoc = (t as { associatedEvent?: string | { _id?: string } }).associatedEvent;
|
||||||
|
|
@ -397,6 +408,7 @@ export function CreateEmailerPanel({
|
||||||
category: form.emailerType || undefined,
|
category: form.emailerType || undefined,
|
||||||
content,
|
content,
|
||||||
associatedEvent: form.associatedEvent.trim() || null,
|
associatedEvent: form.associatedEvent.trim() || null,
|
||||||
|
organization: listOrgId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload);
|
const res = isEdit ? await designTemplatesApi.update(templateId!, payload) : await designTemplatesApi.create(payload);
|
||||||
|
|
@ -437,7 +449,11 @@ export function CreateEmailerPanel({
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isEdit ? "Edit Emailer" : "Create Emailer"}
|
title={isEdit ? "Edit Emailer" : "Create Emailer"}
|
||||||
breadcrumb="Design · Emailers"
|
breadcrumb="Design · Emailers"
|
||||||
description="Design a branded HTML emailer and optional draft campaign."
|
description={
|
||||||
|
editingGlobalTemplate
|
||||||
|
? "Global template — saving creates your organization's customized copy."
|
||||||
|
: "Design a branded HTML emailer and optional draft campaign."
|
||||||
|
}
|
||||||
completedCount={acc.done.size}
|
completedCount={acc.done.size}
|
||||||
totalSections={TOTAL}
|
totalSections={TOTAL}
|
||||||
ctaLabel={isEdit ? "Save changes" : "Save emailer"}
|
ctaLabel={isEdit ? "Save changes" : "Save emailer"}
|
||||||
|
|
|
||||||
|
|
@ -429,18 +429,33 @@ export function CreateEventPanel({
|
||||||
disabled={form.registrationRequired !== "Yes"}
|
disabled={form.registrationRequired !== "Yes"}
|
||||||
>
|
>
|
||||||
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
||||||
<option>Open</option><option>Invite Only</option><option>Approval Required</option>
|
<option>Open</option><option>Invite Only</option>
|
||||||
</select>
|
</select>
|
||||||
</SelectWrap>
|
</SelectWrap>
|
||||||
</Field>
|
</Field>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
|
<Field>
|
||||||
|
<Label text="Registration Approval" required={form.registrationRequired === "Yes"} />
|
||||||
|
<RadioGroup
|
||||||
|
name="registrationApproval"
|
||||||
|
value={form.registrationApproval}
|
||||||
|
onChange={v => f("registrationApproval", v as "auto" | "manual")}
|
||||||
|
options={[
|
||||||
|
{ label: "Auto Confirm", value: "auto" },
|
||||||
|
{ label: "Manual Confirm", value: "manual" },
|
||||||
|
]}
|
||||||
|
disabled={form.registrationRequired !== "Yes"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Verify Email" required />
|
<Label text="Verify Email" required />
|
||||||
<RadioGroup name="verifyEmail" value={form.verifyEmail}
|
<RadioGroup name="verifyEmail" value={form.verifyEmail}
|
||||||
onChange={v => f("verifyEmail", v as "Yes" | "No")}
|
onChange={v => f("verifyEmail", v as "Yes" | "No")}
|
||||||
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
|
||||||
</Field>
|
</Field>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Registration Deadline" />
|
<Label text="Registration Deadline" />
|
||||||
<input type="date" style={IS} value={form.registrationDeadline} onChange={e => f("registrationDeadline", e.target.value)} />
|
<input type="date" style={IS} value={form.registrationDeadline} onChange={e => f("registrationDeadline", e.target.value)} />
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,7 @@ interface MergedForm {
|
||||||
virtualLink: string;
|
virtualLink: string;
|
||||||
registrationRequired: "Yes" | "No";
|
registrationRequired: "Yes" | "No";
|
||||||
registrationMode: string;
|
registrationMode: string;
|
||||||
|
registrationApproval: "auto" | "manual";
|
||||||
verifyEmail: "Yes" | "No";
|
verifyEmail: "Yes" | "No";
|
||||||
registrationDeadline: string;
|
registrationDeadline: string;
|
||||||
authenticationType: string;
|
authenticationType: string;
|
||||||
|
|
@ -459,6 +460,7 @@ const INITIAL: MergedForm = {
|
||||||
virtualLink: "",
|
virtualLink: "",
|
||||||
registrationRequired: "No",
|
registrationRequired: "No",
|
||||||
registrationMode: "",
|
registrationMode: "",
|
||||||
|
registrationApproval: "auto",
|
||||||
verifyEmail: "Yes",
|
verifyEmail: "Yes",
|
||||||
registrationDeadline: "",
|
registrationDeadline: "",
|
||||||
authenticationType: "otp_email",
|
authenticationType: "otp_email",
|
||||||
|
|
@ -859,6 +861,7 @@ export function CreateEventSessionPanel({
|
||||||
virtualLink: form.virtualLink,
|
virtualLink: form.virtualLink,
|
||||||
registrationRequired: form.registrationRequired,
|
registrationRequired: form.registrationRequired,
|
||||||
registrationMode: form.registrationMode,
|
registrationMode: form.registrationMode,
|
||||||
|
registrationApproval: form.registrationApproval,
|
||||||
verifyEmail: form.verifyEmail,
|
verifyEmail: form.verifyEmail,
|
||||||
registrationDeadline: form.registrationDeadline,
|
registrationDeadline: form.registrationDeadline,
|
||||||
authenticationType: form.authenticationType,
|
authenticationType: form.authenticationType,
|
||||||
|
|
@ -1593,12 +1596,24 @@ export function CreateEventSessionPanel({
|
||||||
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
<option value="">{form.registrationRequired === "Yes" ? "Select mode" : "N/A"}</option>
|
||||||
<option>Open</option>
|
<option>Open</option>
|
||||||
<option>Invite Only</option>
|
<option>Invite Only</option>
|
||||||
<option>Approval Required</option>
|
|
||||||
</select>
|
</select>
|
||||||
</SelectWrap>
|
</SelectWrap>
|
||||||
</Field>
|
</Field>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
|
<Field>
|
||||||
|
<Label text="Registration approval" required={form.registrationRequired === "Yes"} />
|
||||||
|
<RadioGroup
|
||||||
|
name="registrationApproval"
|
||||||
|
value={form.registrationApproval}
|
||||||
|
onChange={v => f("registrationApproval", v as "auto" | "manual")}
|
||||||
|
options={[
|
||||||
|
{ label: "Auto Confirm", value: "auto" },
|
||||||
|
{ label: "Manual Confirm", value: "manual" },
|
||||||
|
]}
|
||||||
|
disabled={form.registrationRequired !== "Yes"}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Verify email" required />
|
<Label text="Verify email" required />
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|
@ -1611,6 +1626,8 @@ export function CreateEventSessionPanel({
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</Row>
|
||||||
|
<Row>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Registration deadline" />
|
<Label text="Registration deadline" />
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||||
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
|
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { usePermission } from "../../hooks/usePermission";
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
|
import { eventsApi, formsApi, participantListsApi, designTemplatesApi, portfolioListOrganizationId } from "../../../services/api";
|
||||||
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
|
import type { Form, FormField, ParticipantListRecord, DesignTemplate } from "../../../services/api";
|
||||||
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
|
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
|
||||||
import {
|
import {
|
||||||
EVENT_HEADER_TOGGLE_LABELS,
|
EVENT_HEADER_TOGGLE_LABELS,
|
||||||
|
|
@ -44,6 +44,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
||||||
nextPatch.options = [];
|
nextPatch.options = [];
|
||||||
nextPatch.mapsTo = "";
|
nextPatch.mapsTo = "";
|
||||||
nextPatch.customMapsKey = "";
|
nextPatch.customMapsKey = "";
|
||||||
|
nextPatch.convertToTag = false;
|
||||||
return nextPatch;
|
return nextPatch;
|
||||||
}
|
}
|
||||||
if (OPTION_FIELD_TYPES.has(next)) {
|
if (OPTION_FIELD_TYPES.has(next)) {
|
||||||
|
|
@ -55,12 +56,22 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
||||||
nextPatch.allowOther = false;
|
nextPatch.allowOther = false;
|
||||||
nextPatch.otherLabel = undefined;
|
nextPatch.otherLabel = undefined;
|
||||||
}
|
}
|
||||||
|
if (!canConvertValueToTag(next)) {
|
||||||
|
nextPatch.convertToTag = false;
|
||||||
|
}
|
||||||
return nextPatch;
|
return nextPatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
|
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
|
||||||
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
|
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
|
||||||
|
|
||||||
|
/** Choice / selectable fields whose submitted labels can become participant tags. */
|
||||||
|
const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]);
|
||||||
|
|
||||||
|
function canConvertValueToTag(type: string): boolean {
|
||||||
|
return CONVERT_TO_TAG_FIELD_TYPES.has(type);
|
||||||
|
}
|
||||||
|
|
||||||
const FORM_Q_DRAG = "application/x-eventbuzz-form-q";
|
const FORM_Q_DRAG = "application/x-eventbuzz-form-q";
|
||||||
|
|
||||||
function reorderList<T>(items: T[], from: number, to: number): T[] {
|
function reorderList<T>(items: T[], from: number, to: number): T[] {
|
||||||
|
|
@ -183,6 +194,8 @@ interface Question {
|
||||||
fixed?: boolean;
|
fixed?: boolean;
|
||||||
/** Optional note shown below the field on the public form. */
|
/** Optional note shown below the field on the public form. */
|
||||||
remarks?: string;
|
remarks?: string;
|
||||||
|
/** Registration: add submitted value(s) as participant tags. */
|
||||||
|
convertToTag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedOptions(q: Question): string[] {
|
function normalizedOptions(q: Question): string[] {
|
||||||
|
|
@ -429,6 +442,18 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReord
|
||||||
<HelperText text="Stored under Participant.customFields[key]." />
|
<HelperText text="Stored under Participant.customFields[key]." />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
{canConvertValueToTag(q.type) ? (
|
||||||
|
<Field span2>
|
||||||
|
<Label text="Convert value to tag" />
|
||||||
|
<RadioGroup
|
||||||
|
name={`tag-${q.id}`}
|
||||||
|
value={q.convertToTag ? "Yes" : "No"}
|
||||||
|
onChange={(v) => onPatch({ convertToTag: v === "Yes" })}
|
||||||
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||||
|
/>
|
||||||
|
<HelperText text="When Yes, the selected option(s) are added as participant tags." />
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{!isSection ? (
|
{!isSection ? (
|
||||||
|
|
@ -452,14 +477,25 @@ interface FForm {
|
||||||
questions: Question[];
|
questions: Question[];
|
||||||
showThankYou: "Yes" | "No";
|
showThankYou: "Yes" | "No";
|
||||||
thankYouMsg: string;
|
thankYouMsg: string;
|
||||||
|
formClosedMessage: string;
|
||||||
|
submitErrorMessage: string;
|
||||||
|
captchaFailedMessage: string;
|
||||||
|
registrationClosedMessage: string;
|
||||||
|
registrationDeadlineMessage: string;
|
||||||
|
registrationFullMessage: string;
|
||||||
|
duplicateEmailMessage: string;
|
||||||
|
duplicatePhoneMessage: string;
|
||||||
collectEmail: "Yes" | "No";
|
collectEmail: "Yes" | "No";
|
||||||
anonymous: "Yes" | "No";
|
anonymous: "Yes" | "No";
|
||||||
publish: "Draft" | "Published";
|
publish: "Draft" | "Published";
|
||||||
paymentEnabled: "Yes" | "No";
|
paymentEnabled: "Yes" | "No";
|
||||||
amount: string;
|
amount: string;
|
||||||
confirmationEmail: "Yes" | "No";
|
confirmationEmail: "Yes" | "No";
|
||||||
|
confirmationEmailTemplateId: string;
|
||||||
onSubmitTags: string;
|
onSubmitTags: string;
|
||||||
onSubmitListId: string;
|
onSubmitListId: string;
|
||||||
|
allowDuplicateEmail: "Yes" | "No";
|
||||||
|
allowDuplicatePhone: "Yes" | "No";
|
||||||
headerShowLogo: "Yes" | "No";
|
headerShowLogo: "Yes" | "No";
|
||||||
headerShowTitle: "Yes" | "No";
|
headerShowTitle: "Yes" | "No";
|
||||||
headerShowDateRange: "Yes" | "No";
|
headerShowDateRange: "Yes" | "No";
|
||||||
|
|
@ -468,6 +504,19 @@ interface FForm {
|
||||||
headerShowVenue: "Yes" | "No";
|
headerShowVenue: "Yes" | "No";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM_MESSAGES = {
|
||||||
|
thankYouFeedback: "Thank you for your feedback!",
|
||||||
|
thankYouResponse: "Thank you for your response!",
|
||||||
|
formClosed: "This form is not accepting responses.",
|
||||||
|
submitError: "Submit failed. Please try again.",
|
||||||
|
captchaFailed: "Captcha verification failed. Please try again.",
|
||||||
|
registrationClosed: "Registration is closed for this event.",
|
||||||
|
registrationDeadline: "Registration deadline has passed.",
|
||||||
|
registrationFull: "This event has reached maximum participants.",
|
||||||
|
duplicateEmail: "This email is already registered for this event.",
|
||||||
|
duplicatePhone: "This phone number is already registered for this event.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const newQ = (types: string[]): Question => {
|
const newQ = (types: string[]): Question => {
|
||||||
const first = types[0];
|
const first = types[0];
|
||||||
return {
|
return {
|
||||||
|
|
@ -487,15 +536,26 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
|
||||||
description: "",
|
description: "",
|
||||||
questions: [newQ(qTypes)],
|
questions: [newQ(qTypes)],
|
||||||
showThankYou: "Yes",
|
showThankYou: "Yes",
|
||||||
thankYouMsg: "Thank you for your feedback!",
|
thankYouMsg: isFeedback ? DEFAULT_FORM_MESSAGES.thankYouFeedback : DEFAULT_FORM_MESSAGES.thankYouResponse,
|
||||||
|
formClosedMessage: DEFAULT_FORM_MESSAGES.formClosed,
|
||||||
|
submitErrorMessage: DEFAULT_FORM_MESSAGES.submitError,
|
||||||
|
captchaFailedMessage: DEFAULT_FORM_MESSAGES.captchaFailed,
|
||||||
|
registrationClosedMessage: DEFAULT_FORM_MESSAGES.registrationClosed,
|
||||||
|
registrationDeadlineMessage: DEFAULT_FORM_MESSAGES.registrationDeadline,
|
||||||
|
registrationFullMessage: DEFAULT_FORM_MESSAGES.registrationFull,
|
||||||
|
duplicateEmailMessage: DEFAULT_FORM_MESSAGES.duplicateEmail,
|
||||||
|
duplicatePhoneMessage: DEFAULT_FORM_MESSAGES.duplicatePhone,
|
||||||
collectEmail: "No",
|
collectEmail: "No",
|
||||||
anonymous: isFeedback ? "Yes" : "No",
|
anonymous: isFeedback ? "Yes" : "No",
|
||||||
publish: "Draft",
|
publish: "Draft",
|
||||||
paymentEnabled: "No",
|
paymentEnabled: "No",
|
||||||
amount: "",
|
amount: "",
|
||||||
confirmationEmail: "Yes",
|
confirmationEmail: "No",
|
||||||
|
confirmationEmailTemplateId: "",
|
||||||
onSubmitTags: "",
|
onSubmitTags: "",
|
||||||
onSubmitListId: "",
|
onSubmitListId: "",
|
||||||
|
allowDuplicateEmail: "Yes",
|
||||||
|
allowDuplicatePhone: "Yes",
|
||||||
headerShowLogo: "Yes",
|
headerShowLogo: "Yes",
|
||||||
headerShowTitle: "Yes",
|
headerShowTitle: "Yes",
|
||||||
headerShowDateRange: "Yes",
|
headerShowDateRange: "Yes",
|
||||||
|
|
@ -627,6 +687,28 @@ function toApiField(q: Question, mode: FormMode): FormField {
|
||||||
}
|
}
|
||||||
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
|
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
|
||||||
}
|
}
|
||||||
|
// Registration / web-check-in: also absorb feedback-style labels that may linger in legacy forms.
|
||||||
|
if (q.type === "Star Rating") {
|
||||||
|
return withFieldRemarks({ label, name, type: "stars", required, options: ["1", "2", "3", "4", "5"] }, q.remarks);
|
||||||
|
}
|
||||||
|
if (q.type === "NPS (0–10)") {
|
||||||
|
return withFieldRemarks({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
type: "select",
|
||||||
|
required,
|
||||||
|
options: Array.from({ length: 11 }, (_, i) => String(i)),
|
||||||
|
}, q.remarks);
|
||||||
|
}
|
||||||
|
if (q.type === "Multiple Choice" || q.type === "Short Text" || q.type === "Long Text") {
|
||||||
|
if (q.type === "Multiple Choice") {
|
||||||
|
return withFieldRemarks({ label, name, type: "select", required, options: choiceOpts }, q.remarks);
|
||||||
|
}
|
||||||
|
if (q.type === "Long Text") {
|
||||||
|
return withFieldRemarks({ label, name, type: "textarea", required }, q.remarks);
|
||||||
|
}
|
||||||
|
return withFieldRemarks({ label, name, type: "text", required }, q.remarks);
|
||||||
|
}
|
||||||
const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"];
|
const type = (REG_TYPE_TO_API[q.type] ?? "text") as FormField["type"];
|
||||||
if (type === "select" || type === "radio" || type === "checkboxes") {
|
if (type === "select" || type === "radio" || type === "checkboxes") {
|
||||||
const base = { label, name, type, required, options: choiceOpts };
|
const base = { label, name, type, required, options: choiceOpts };
|
||||||
|
|
@ -648,6 +730,7 @@ function toApiFieldReg(q: Question): FormField {
|
||||||
mapsTo: q.mapsTo || undefined,
|
mapsTo: q.mapsTo || undefined,
|
||||||
customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined,
|
customMapsKey: q.mapsTo === "custom" && q.customMapsKey?.trim() ? q.customMapsKey.trim() : undefined,
|
||||||
isFixed: q.fixed === true,
|
isFixed: q.fixed === true,
|
||||||
|
convertToTag: canConvertValueToTag(q.type) && q.convertToTag === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -673,6 +756,7 @@ function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode:
|
||||||
customMapsKey: f.customMapsKey,
|
customMapsKey: f.customMapsKey,
|
||||||
fixed: f.isFixed,
|
fixed: f.isFixed,
|
||||||
remarks: f.remarks ?? "",
|
remarks: f.remarks ?? "",
|
||||||
|
convertToTag: f.convertToTag === true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return assignUniqueFieldNames(questions);
|
return assignUniqueFieldNames(questions);
|
||||||
|
|
@ -693,6 +777,7 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
|
||||||
customMapsKey: f.customMapsKey,
|
customMapsKey: f.customMapsKey,
|
||||||
fixed: f.isFixed === true,
|
fixed: f.isFixed === true,
|
||||||
remarks: f.remarks ?? "",
|
remarks: f.remarks ?? "",
|
||||||
|
convertToTag: f.convertToTag === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -730,7 +815,7 @@ export function CreateFormPanel({
|
||||||
const isOther = mode === "other";
|
const isOther = mode === "other";
|
||||||
const isWebCheckIn = mode === "web-check-in";
|
const isWebCheckIn = mode === "web-check-in";
|
||||||
const TOTAL = isReg ? 5 : 3;
|
const TOTAL = isReg ? 5 : 3;
|
||||||
const qTypes = isReg || isWebCheckIn ? OTHER_Q_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
|
const qTypes = isReg || isWebCheckIn ? REG_FIELD_TYPES : isOther ? OTHER_Q_TYPES : FEEDBACK_Q_TYPES;
|
||||||
const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn;
|
const isFeedbackOrOther = isFeedback || isOther || isWebCheckIn;
|
||||||
const acc = useAccordion(TOTAL);
|
const acc = useAccordion(TOTAL);
|
||||||
|
|
||||||
|
|
@ -741,6 +826,7 @@ export function CreateFormPanel({
|
||||||
return base;
|
return base;
|
||||||
});
|
});
|
||||||
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
const [fixedLists, setFixedLists] = useState<ParticipantListRecord[]>([]);
|
||||||
|
const [emailerTemplates, setEmailerTemplates] = useState<DesignTemplate[]>([]);
|
||||||
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
|
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(!!formId);
|
const [loading, setLoading] = useState(!!formId);
|
||||||
|
|
@ -788,6 +874,27 @@ export function CreateFormPanel({
|
||||||
}).catch(() => setFixedLists([]));
|
}).catch(() => setFixedLists([]));
|
||||||
}, [isReg, listOrgId]);
|
}, [isReg, listOrgId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReg) return;
|
||||||
|
if (!listOrgId) {
|
||||||
|
setEmailerTemplates([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void designTemplatesApi
|
||||||
|
.list({ type: "emailer", organization: listOrgId, limit: 200 })
|
||||||
|
.then((r) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmailerTemplates(r.data?.templates ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setEmailerTemplates([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isReg, listOrgId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!formId) return;
|
if (!formId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -808,15 +915,30 @@ export function CreateFormPanel({
|
||||||
description: data.description ?? "",
|
description: data.description ?? "",
|
||||||
questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]),
|
questions: questions.length ? questions : (data.type === "registration" ? initialRegQuestions() : [newQ(qTypes)]),
|
||||||
showThankYou: s.showThankYou === false ? "No" : "Yes",
|
showThankYou: s.showThankYou === false ? "No" : "Yes",
|
||||||
thankYouMsg: s.thankYouMessage?.trim() ? s.thankYouMessage : "Thank you for your feedback!",
|
thankYouMsg: s.thankYouMessage?.trim()
|
||||||
|
? s.thankYouMessage
|
||||||
|
: (isFeedbackOrOther ? DEFAULT_FORM_MESSAGES.thankYouFeedback : DEFAULT_FORM_MESSAGES.thankYouResponse),
|
||||||
|
formClosedMessage: s.formClosedMessage?.trim() || DEFAULT_FORM_MESSAGES.formClosed,
|
||||||
|
submitErrorMessage: s.submitErrorMessage?.trim() || DEFAULT_FORM_MESSAGES.submitError,
|
||||||
|
captchaFailedMessage: s.captchaFailedMessage?.trim() || DEFAULT_FORM_MESSAGES.captchaFailed,
|
||||||
|
registrationClosedMessage: s.registrationClosedMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationClosed,
|
||||||
|
registrationDeadlineMessage: s.registrationDeadlineMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationDeadline,
|
||||||
|
registrationFullMessage: s.registrationFullMessage?.trim() || DEFAULT_FORM_MESSAGES.registrationFull,
|
||||||
|
duplicateEmailMessage: s.duplicateEmailMessage?.trim() || DEFAULT_FORM_MESSAGES.duplicateEmail,
|
||||||
|
duplicatePhoneMessage: s.duplicatePhoneMessage?.trim() || DEFAULT_FORM_MESSAGES.duplicatePhone,
|
||||||
collectEmail: s.collectEmail ? "Yes" : "No",
|
collectEmail: s.collectEmail ? "Yes" : "No",
|
||||||
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
|
anonymous: s.allowAnonymous ? "Yes" : (isFeedbackOrOther ? "Yes" : "No"),
|
||||||
publish: s.isActive !== false ? "Published" : "Draft",
|
publish: s.isActive !== false ? "Published" : "Draft",
|
||||||
paymentEnabled: s.paymentEnabled ? "Yes" : "No",
|
paymentEnabled: s.paymentEnabled ? "Yes" : "No",
|
||||||
amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "",
|
amount: s.paymentAmount != null && s.paymentAmount !== "" ? String(s.paymentAmount) : "",
|
||||||
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
|
confirmationEmail: s.sendConfirmationEmail === true ? "Yes" : "No",
|
||||||
|
confirmationEmailTemplateId: s.confirmationEmailTemplateId
|
||||||
|
? String(s.confirmationEmailTemplateId)
|
||||||
|
: "",
|
||||||
onSubmitTags: tags,
|
onSubmitTags: tags,
|
||||||
onSubmitListId: s.onSubmitParticipantListId ?? "",
|
onSubmitListId: s.onSubmitParticipantListId ?? "",
|
||||||
|
allowDuplicateEmail: s.allowDuplicateEmail === false ? "No" : "Yes",
|
||||||
|
allowDuplicatePhone: s.allowDuplicatePhone === false ? "No" : "Yes",
|
||||||
headerShowLogo: header.showLogo,
|
headerShowLogo: header.showLogo,
|
||||||
headerShowTitle: header.showTitle,
|
headerShowTitle: header.showTitle,
|
||||||
headerShowDateRange: header.showDateRange,
|
headerShowDateRange: header.showDateRange,
|
||||||
|
|
@ -876,7 +998,11 @@ export function CreateFormPanel({
|
||||||
return anyLabel && choicesOk;
|
return anyLabel && choicesOk;
|
||||||
}
|
}
|
||||||
if (n === 3 && isReg) return true;
|
if (n === 3 && isReg) return true;
|
||||||
if (n === 4 && isReg) return true;
|
if (n === 4 && isReg) {
|
||||||
|
if (f.confirmationEmail === "Yes" && !f.confirmationEmailTemplateId.trim()) return false;
|
||||||
|
if (f.paymentEnabled === "Yes" && !f.amount.trim()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (n === (isReg ? 5 : 3)) return true;
|
if (n === (isReg ? 5 : 3)) return true;
|
||||||
return true;
|
return true;
|
||||||
}, [isReg, isWebCheckIn, selectedEventId]);
|
}, [isReg, isWebCheckIn, selectedEventId]);
|
||||||
|
|
@ -910,6 +1036,13 @@ export function CreateFormPanel({
|
||||||
paymentEnabled: form.paymentEnabled === "Yes",
|
paymentEnabled: form.paymentEnabled === "Yes",
|
||||||
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
|
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
|
||||||
sendConfirmationEmail: form.confirmationEmail === "Yes",
|
sendConfirmationEmail: form.confirmationEmail === "Yes",
|
||||||
|
confirmationEmailTemplateId:
|
||||||
|
form.confirmationEmail === "Yes" && form.confirmationEmailTemplateId.trim()
|
||||||
|
? form.confirmationEmailTemplateId.trim()
|
||||||
|
: null,
|
||||||
|
formClosedMessage: form.formClosedMessage.trim(),
|
||||||
|
submitErrorMessage: form.submitErrorMessage.trim(),
|
||||||
|
captchaFailedMessage: form.captchaFailedMessage.trim(),
|
||||||
eventHeader: eventHeaderToApi({
|
eventHeader: eventHeaderToApi({
|
||||||
showLogo: form.headerShowLogo,
|
showLogo: form.headerShowLogo,
|
||||||
showTitle: form.headerShowTitle,
|
showTitle: form.headerShowTitle,
|
||||||
|
|
@ -921,7 +1054,14 @@ export function CreateFormPanel({
|
||||||
...(isReg
|
...(isReg
|
||||||
? {
|
? {
|
||||||
onSubmitAddTags: tagArr,
|
onSubmitAddTags: tagArr,
|
||||||
onSubmitParticipantListId: form.onSubmitListId.trim() || null,
|
onSubmitParticipantListId: form.onSubmitListId?.trim() || null,
|
||||||
|
allowDuplicateEmail: form.allowDuplicateEmail !== "No",
|
||||||
|
allowDuplicatePhone: form.allowDuplicatePhone !== "No",
|
||||||
|
registrationClosedMessage: form.registrationClosedMessage.trim(),
|
||||||
|
registrationDeadlineMessage: form.registrationDeadlineMessage.trim(),
|
||||||
|
registrationFullMessage: form.registrationFullMessage.trim(),
|
||||||
|
duplicateEmailMessage: form.duplicateEmailMessage.trim(),
|
||||||
|
duplicatePhoneMessage: form.duplicatePhoneMessage.trim(),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
|
@ -944,8 +1084,9 @@ export function CreateFormPanel({
|
||||||
}
|
}
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch (e) {
|
||||||
setSectionErrors(prev => ({ ...prev, 1: "Failed to save. Please try again." }));
|
const msg = e instanceof Error && e.message.trim() ? e.message.trim() : "Failed to save. Please try again.";
|
||||||
|
setSectionErrors((prev) => ({ ...prev, 1: msg }));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1118,7 +1259,7 @@ export function CreateFormPanel({
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Add tags on submit" />
|
<Label text="Add tags on submit" />
|
||||||
<input style={IS} placeholder="e.g. VIP, Registered — comma separated" value={form.onSubmitTags} onChange={e => set("onSubmitTags", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
<input style={IS} placeholder="e.g. VIP, Registered — comma separated" value={form.onSubmitTags} onChange={e => set("onSubmitTags", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
||||||
<HelperText text="Merged onto the participant record (deduplicated)." />
|
<HelperText text="Merged onto the participant record (deduplicated). Selectable fields (Dropdown, Radio, Checkboxes, etc.) can also add tags via “Convert value to tag”." />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Add to fixed participant list" />
|
<Label text="Add to fixed participant list" />
|
||||||
|
|
@ -1132,6 +1273,28 @@ export function CreateFormPanel({
|
||||||
</div>
|
</div>
|
||||||
<HelperText text="Only fixed (manual) lists in your organization are shown." />
|
<HelperText text="Only fixed (manual) lists in your organization are shown." />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Grid cols={2}>
|
||||||
|
<Field>
|
||||||
|
<Label text="Allow duplicate email" required />
|
||||||
|
<RadioGroup
|
||||||
|
name="allowDupEmail"
|
||||||
|
value={form.allowDuplicateEmail}
|
||||||
|
onChange={v => set("allowDuplicateEmail", v as FForm["allowDuplicateEmail"])}
|
||||||
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||||
|
/>
|
||||||
|
<HelperText text="Case-insensitive. When No, emails already registered for this event are rejected." />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Allow duplicate phone" required />
|
||||||
|
<RadioGroup
|
||||||
|
name="allowDupPhone"
|
||||||
|
value={form.allowDuplicatePhone}
|
||||||
|
onChange={v => set("allowDuplicatePhone", v as FForm["allowDuplicatePhone"])}
|
||||||
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||||
|
/>
|
||||||
|
<HelperText text="Compares the last 10 digits only (ignores +, spaces, country code)." />
|
||||||
|
</Field>
|
||||||
|
</Grid>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1160,9 +1323,50 @@ export function CreateFormPanel({
|
||||||
)}
|
)}
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Confirmation Email" required />
|
<Label text="Confirmation Email" required />
|
||||||
<RadioGroup name="confEmail" value={form.confirmationEmail} onChange={v => set("confirmationEmail", v as FForm["confirmationEmail"])} options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]} />
|
<RadioGroup
|
||||||
<HelperText text="Send a confirmation email after registration." />
|
name="confEmail"
|
||||||
|
value={form.confirmationEmail}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = v as FForm["confirmationEmail"];
|
||||||
|
if (next === "Yes") {
|
||||||
|
const preferred =
|
||||||
|
emailerTemplates.find((t) => t.systemTemplateKey === "register_success")?._id
|
||||||
|
|| emailerTemplates.find((t) => t.systemTemplateKey === "register_confirmation")?._id
|
||||||
|
|| emailerTemplates[0]?._id
|
||||||
|
|| "";
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
confirmationEmail: next,
|
||||||
|
confirmationEmailTemplateId: prev.confirmationEmailTemplateId || preferred,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
set("confirmationEmail", next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||||
|
/>
|
||||||
|
<HelperText text="When Yes, send an emailer template after a successful registration." />
|
||||||
</Field>
|
</Field>
|
||||||
|
{form.confirmationEmail === "Yes" && (
|
||||||
|
<Field span2>
|
||||||
|
<Label text="Confirmation email template" required />
|
||||||
|
<select
|
||||||
|
style={SS}
|
||||||
|
value={form.confirmationEmailTemplateId}
|
||||||
|
onChange={(e) => set("confirmationEmailTemplateId", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
<option value="">Select an email template…</option>
|
||||||
|
{emailerTemplates.map((t) => (
|
||||||
|
<option key={t._id} value={t._id}>
|
||||||
|
{t.name}{t.isSystemTemplate || t.isGlobalTemplate ? " (system)" : ""}{t.category ? ` · ${t.category}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<HelperText text={emailerTemplates.length ? "Choose from Design → Emailers (system and organisation templates)." : "No emailer templates found. Create one under Design → Emailers."} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1170,7 +1374,7 @@ export function CreateFormPanel({
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
n={isReg ? 5 : 3}
|
n={isReg ? 5 : 3}
|
||||||
title="Publish Settings"
|
title="Publish Settings"
|
||||||
subtitle="Set the form status and thank-you message"
|
subtitle="Status and respondent-facing messages"
|
||||||
open={acc.open === (isReg ? 5 : 3)}
|
open={acc.open === (isReg ? 5 : 3)}
|
||||||
completed={acc.done.has(isReg ? 5 : 3)}
|
completed={acc.done.has(isReg ? 5 : 3)}
|
||||||
onToggle={() => acc.toggle(isReg ? 5 : 3)}
|
onToggle={() => acc.toggle(isReg ? 5 : 3)}
|
||||||
|
|
@ -1188,12 +1392,129 @@ export function CreateFormPanel({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Show thank-you screen" required />
|
||||||
|
<RadioGroup
|
||||||
|
name="showThankYou"
|
||||||
|
value={form.showThankYou}
|
||||||
|
onChange={(v) => set("showThankYou", v as FForm["showThankYou"])}
|
||||||
|
options={[{ label: "Yes", value: "Yes" }, { label: "No", value: "No" }]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field span2>
|
<Field span2>
|
||||||
<Label text="Thank You Message" />
|
<Label text="Success / thank-you message" />
|
||||||
<input style={IS} placeholder="Thank you for your response!" value={form.thankYouMsg} onChange={e => set("thankYouMsg", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
<textarea
|
||||||
<HelperText text="Shown to respondents after they submit the form." />
|
style={{ ...IS, resize: "vertical", minHeight: "64px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.thankYouResponse}
|
||||||
|
value={form.thankYouMsg}
|
||||||
|
onChange={e => set("thankYouMsg", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<HelperText text="Shown after a successful submission." />
|
||||||
|
</Field>
|
||||||
|
<Field span2>
|
||||||
|
<Label text="Form closed message" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.formClosed}
|
||||||
|
value={form.formClosedMessage}
|
||||||
|
onChange={e => set("formClosedMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<HelperText text="Shown when the form is Draft / not accepting responses." />
|
||||||
|
</Field>
|
||||||
|
<Field span2>
|
||||||
|
<Label text="Generic error message" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.submitError}
|
||||||
|
value={form.submitErrorMessage}
|
||||||
|
onChange={e => set("submitErrorMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<HelperText text="Fallback when submit fails without a more specific API message." />
|
||||||
|
</Field>
|
||||||
|
<Field span2>
|
||||||
|
<Label text="Captcha failed message" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.captchaFailed}
|
||||||
|
value={form.captchaFailedMessage}
|
||||||
|
onChange={e => set("captchaFailedMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{isReg ? (
|
||||||
|
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-xs)", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--muted)" }}>
|
||||||
|
Registration messages
|
||||||
|
</p>
|
||||||
|
<Field>
|
||||||
|
<Label text="Registration closed" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.registrationClosed}
|
||||||
|
value={form.registrationClosedMessage}
|
||||||
|
onChange={e => set("registrationClosedMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Registration deadline passed" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.registrationDeadline}
|
||||||
|
value={form.registrationDeadlineMessage}
|
||||||
|
onChange={e => set("registrationDeadlineMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Registration full" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.registrationFull}
|
||||||
|
value={form.registrationFullMessage}
|
||||||
|
onChange={e => set("registrationFullMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Duplicate email" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.duplicateEmail}
|
||||||
|
value={form.duplicateEmailMessage}
|
||||||
|
onChange={e => set("duplicateEmailMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<HelperText text="Used when “Allow duplicate email” is No." />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Duplicate phone" />
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, resize: "vertical", minHeight: "56px" }}
|
||||||
|
placeholder={DEFAULT_FORM_MESSAGES.duplicatePhone}
|
||||||
|
value={form.duplicatePhoneMessage}
|
||||||
|
onChange={e => set("duplicatePhoneMessage", e.target.value)}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<HelperText text="Used when “Allow duplicate phone” is No." />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Event header on public form" />
|
<Label text="Event header on public form" />
|
||||||
<HelperText text="Choose which linked event details appear above the form on the public page." />
|
<HelperText text="Choose which linked event details appear above the form on the public page." />
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,18 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = Boolean(q.trim() || dateFrom || dateTo || appliedQ || appliedFrom || appliedTo);
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setQ("");
|
||||||
|
setDateFrom("");
|
||||||
|
setDateTo("");
|
||||||
|
setAppliedQ("");
|
||||||
|
setAppliedFrom("");
|
||||||
|
setAppliedTo("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
const onExport = async () => {
|
const onExport = async () => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -383,6 +395,25 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearFilters}
|
||||||
|
disabled={!hasActiveFilters}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: hasActiveFilters ? "pointer" : "not-allowed",
|
||||||
|
opacity: hasActiveFilters ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void onExport()}
|
onClick={() => void onExport()}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* Map a saved BadgeBatch (config) into CreateBadgePanel form state.
|
* Map a saved BadgeBatch (config) into CreateBadgePanel form state.
|
||||||
*/
|
*/
|
||||||
import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches";
|
import type { BadgeBatchRecord, BadgeLayoutElementPayload } from "../../../services/api/badgeBatches";
|
||||||
import type { BadgeLayoutElement } from "./badgeLayout";
|
import type { BadgeBackgroundImageLayout, BadgeLayoutElement } from "./badgeLayout";
|
||||||
import { defaultLayoutForFields } from "./badgeLayout";
|
import { defaultBackgroundImageLayout, defaultLayoutForFields, parseBackgroundImageLayout } from "./badgeLayout";
|
||||||
|
|
||||||
export type BadgeFormState = {
|
export type BadgeFormState = {
|
||||||
formName: string;
|
formName: string;
|
||||||
|
|
@ -19,6 +19,7 @@ export type BadgeFormState = {
|
||||||
backgroundMode: "image" | "color";
|
backgroundMode: "image" | "color";
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
backgroundImageUrl: string;
|
backgroundImageUrl: string;
|
||||||
|
backgroundImageLayout: BadgeBackgroundImageLayout;
|
||||||
enabledFields: string[];
|
enabledFields: string[];
|
||||||
layoutElements: BadgeLayoutElement[];
|
layoutElements: BadgeLayoutElement[];
|
||||||
customTexts: { id: string; text: string }[];
|
customTexts: { id: string; text: string }[];
|
||||||
|
|
@ -98,8 +99,12 @@ function customImagesFromConfig(c: Record<string, unknown>, layout: BadgeLayoutE
|
||||||
|
|
||||||
export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState {
|
export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState {
|
||||||
const c = asConfig(batch);
|
const c = asConfig(batch);
|
||||||
const bgMode = str(c.backgroundMode, "color") === "image" ? "image" : "color";
|
|
||||||
const bgColor = str(c.backgroundColor, "#0B4DD1");
|
const bgColor = str(c.backgroundColor, "#0B4DD1");
|
||||||
|
const bgImageUrl = str(c.backgroundImageUrl, "");
|
||||||
|
const bgMode = bgImageUrl.trim() ? "image" : str(c.backgroundMode, "color") === "image" ? "image" : "color";
|
||||||
|
const backgroundImageLayout =
|
||||||
|
parseBackgroundImageLayout(c.backgroundImageLayout) ??
|
||||||
|
(bgImageUrl.trim() ? defaultBackgroundImageLayout() : defaultBackgroundImageLayout());
|
||||||
const fields = Array.isArray(c.fields) ? c.fields.map((f) => String(f)) : ["name", "title", "org", "qr"];
|
const fields = Array.isArray(c.fields) ? c.fields.map((f) => String(f)) : ["name", "title", "org", "qr"];
|
||||||
let layoutElements = layoutFromConfig(c);
|
let layoutElements = layoutFromConfig(c);
|
||||||
if (!layoutElements.length && fields.length) {
|
if (!layoutElements.length && fields.length) {
|
||||||
|
|
@ -125,7 +130,8 @@ export function formStateFromBadgeBatch(batch: BadgeBatchRecord): BadgeFormState
|
||||||
participantListId: c.participantListId != null ? String(c.participantListId) : "",
|
participantListId: c.participantListId != null ? String(c.participantListId) : "",
|
||||||
backgroundMode: bgMode,
|
backgroundMode: bgMode,
|
||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
backgroundImageUrl: str(c.backgroundImageUrl, ""),
|
backgroundImageUrl: bgImageUrl,
|
||||||
|
backgroundImageLayout,
|
||||||
enabledFields: fields,
|
enabledFields: fields,
|
||||||
layoutElements,
|
layoutElements,
|
||||||
customTexts,
|
customTexts,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,59 @@ export type BadgeTextAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
|
export type BadgeLayoutKind = "field" | "custom_text" | "custom_image";
|
||||||
|
|
||||||
|
export type BadgeBackgroundImageLayout = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preview selection id for the optional background image layer. */
|
||||||
|
export const BADGE_BACKGROUND_IMAGE_ID = "__badge_bg__";
|
||||||
|
|
||||||
|
const BG_IMAGE_MIN_SIZE = 10;
|
||||||
|
const BG_IMAGE_MAX_SIZE = 500;
|
||||||
|
const BG_IMAGE_MIN_POS = -400;
|
||||||
|
const BG_IMAGE_MAX_POS = 400;
|
||||||
|
|
||||||
|
export function defaultBackgroundImageLayout(): BadgeBackgroundImageLayout {
|
||||||
|
return { x: 0, y: 0, w: 100, h: 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackgroundImageLayout(raw: unknown): BadgeBackgroundImageLayout | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof o.x !== "number" ||
|
||||||
|
typeof o.y !== "number" ||
|
||||||
|
typeof o.w !== "number" ||
|
||||||
|
typeof o.h !== "number"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return applyBackgroundImageLayoutPatch({ x: o.x, y: o.y, w: o.w, h: o.h });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allow bleed outside the badge — only soft sanity limits for save/drag. */
|
||||||
|
export function applyBackgroundImageLayoutPatch(
|
||||||
|
layout: BadgeBackgroundImageLayout,
|
||||||
|
patch: Partial<BadgeBackgroundImageLayout> = {},
|
||||||
|
): BadgeBackgroundImageLayout {
|
||||||
|
const w = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.w ?? layout.w));
|
||||||
|
const h = Math.min(BG_IMAGE_MAX_SIZE, Math.max(BG_IMAGE_MIN_SIZE, patch.h ?? layout.h));
|
||||||
|
const x = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.x ?? layout.x));
|
||||||
|
const y = Math.min(BG_IMAGE_MAX_POS, Math.max(BG_IMAGE_MIN_POS, patch.y ?? layout.y));
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use applyBackgroundImageLayoutPatch */
|
||||||
|
export function clampBackgroundImageLayout(
|
||||||
|
layout: BadgeBackgroundImageLayout,
|
||||||
|
patch: Partial<BadgeBackgroundImageLayout> = {},
|
||||||
|
): BadgeBackgroundImageLayout {
|
||||||
|
return applyBackgroundImageLayoutPatch(layout, patch);
|
||||||
|
}
|
||||||
|
|
||||||
export type BadgeLayoutElement = {
|
export type BadgeLayoutElement = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: BadgeLayoutKind;
|
kind: BadgeLayoutKind;
|
||||||
|
|
@ -65,6 +118,12 @@ export function elementSizePct(el: BadgeLayoutElement): number {
|
||||||
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
|
return el.w ?? (el.field === "qr" ? 28 : el.kind === "custom_image" ? 25 : 18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Square elements sized by width % of badge — height as % of badge height */
|
||||||
|
export function squareElementHeightPct(widthPct: number, badgeAspectRatio: number): number {
|
||||||
|
const ar = badgeAspectRatio > 0 ? badgeAspectRatio : 1;
|
||||||
|
return widthPct * ar;
|
||||||
|
}
|
||||||
|
|
||||||
export function clampElementSize(el: BadgeLayoutElement, w: number): number {
|
export function clampElementSize(el: BadgeLayoutElement, w: number): number {
|
||||||
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
|
return Math.min(RESIZABLE_SIZE_MAX, Math.max(RESIZABLE_SIZE_MIN, w));
|
||||||
}
|
}
|
||||||
|
|
@ -106,28 +165,24 @@ export function applyElementSize(
|
||||||
const size = clampElementSize(el, w);
|
const size = clampElementSize(el, w);
|
||||||
const patch: Partial<BadgeLayoutElement> = { w: size };
|
const patch: Partial<BadgeLayoutElement> = { w: size };
|
||||||
if (isSquareResizable(el) || el.field === "photo") {
|
if (isSquareResizable(el) || el.field === "photo") {
|
||||||
patch.h = size;
|
patch.h = squareElementHeightPct(size, badgeAspectRatio);
|
||||||
} else if (el.kind === "custom_image") {
|
} else if (el.kind === "custom_image") {
|
||||||
const ar = el.imageAspectRatio ?? 1;
|
const ar = el.imageAspectRatio ?? 1;
|
||||||
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
|
patch.h = heightPctForImageWidth(size, ar, badgeAspectRatio);
|
||||||
}
|
}
|
||||||
const merged = { ...el, ...patch };
|
const merged = { ...el, ...patch };
|
||||||
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y) };
|
return { ...patch, ...clampElementPosition(merged, merged.x, merged.y, badgeAspectRatio) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
|
/** Size in % used for drag clamping (w=0 → no horizontal clamp) */
|
||||||
export function elementBoundsPct(el: BadgeLayoutElement): { w: number; h: number } {
|
export function elementBoundsPct(el: BadgeLayoutElement, badgeAspectRatio = 1): { w: number; h: number } {
|
||||||
if (el.field === "qr") {
|
if (el.field === "qr" || el.field === "photo") {
|
||||||
const s = elementSizePct(el);
|
const s = elementSizePct(el);
|
||||||
return { w: s, h: s };
|
return { w: s, h: squareElementHeightPct(s, badgeAspectRatio) };
|
||||||
}
|
}
|
||||||
if (el.kind === "custom_image") {
|
if (el.kind === "custom_image") {
|
||||||
return { w: el.w ?? 25, h: el.h ?? 25 };
|
return { w: el.w ?? 25, h: el.h ?? 25 };
|
||||||
}
|
}
|
||||||
if (el.field === "photo") {
|
|
||||||
const s = elementSizePct(el);
|
|
||||||
return { w: s, h: s };
|
|
||||||
}
|
|
||||||
if (isTextElement(el)) {
|
if (isTextElement(el)) {
|
||||||
if (usesFullWidthCenter(el)) {
|
if (usesFullWidthCenter(el)) {
|
||||||
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
|
return { w: 0, h: Math.max(4, (el.fontSizePct ?? 5) * 2) };
|
||||||
|
|
@ -141,8 +196,9 @@ export function clampElementPosition(
|
||||||
el: BadgeLayoutElement,
|
el: BadgeLayoutElement,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
|
badgeAspectRatio = 1,
|
||||||
): { x: number; y: number } {
|
): { x: number; y: number } {
|
||||||
const bounds = elementBoundsPct(el);
|
const bounds = elementBoundsPct(el, badgeAspectRatio);
|
||||||
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
|
const maxX = bounds.w > 0 ? Math.max(0, 100 - bounds.w) : 100;
|
||||||
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
|
const maxY = bounds.h > 0 ? Math.max(0, 100 - bounds.h) : 100;
|
||||||
let cx = Math.min(maxX, Math.max(0, x));
|
let cx = Math.min(maxX, Math.max(0, x));
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export interface EventCreateFormInput {
|
||||||
virtualLink: string;
|
virtualLink: string;
|
||||||
registrationRequired: "Yes" | "No";
|
registrationRequired: "Yes" | "No";
|
||||||
registrationMode: string;
|
registrationMode: string;
|
||||||
|
/** Auto Confirm → requiresApproval false; Manual Confirm → requiresApproval true. */
|
||||||
|
registrationApproval?: "auto" | "manual";
|
||||||
verifyEmail: "Yes" | "No";
|
verifyEmail: "Yes" | "No";
|
||||||
registrationDeadline: string;
|
registrationDeadline: string;
|
||||||
authenticationType: string;
|
authenticationType: string;
|
||||||
|
|
@ -310,12 +312,14 @@ export interface BuildEventCreatePayloadOpts {
|
||||||
/** Body shape for POST/PUT /events (matches backend Joi + dashboard usage). */
|
/** Body shape for POST/PUT /events (matches backend Joi + dashboard usage). */
|
||||||
export function buildEventCreatePayload(form: EventCreateFormInput, opts?: BuildEventCreatePayloadOpts): Record<string, unknown> {
|
export function buildEventCreatePayload(form: EventCreateFormInput, opts?: BuildEventCreatePayloadOpts): Record<string, unknown> {
|
||||||
const regReq = form.registrationRequired === "Yes";
|
const regReq = form.registrationRequired === "Yes";
|
||||||
const mode = form.registrationMode;
|
let mode = form.registrationMode;
|
||||||
|
// Legacy value — never persist again; approval is a separate control
|
||||||
|
if (mode === "Approval Required") mode = "Open";
|
||||||
let registrationOpen = true;
|
let registrationOpen = true;
|
||||||
let requiresApproval = false;
|
let requiresApproval = false;
|
||||||
if (regReq) {
|
if (regReq) {
|
||||||
if (mode === "Invite Only") registrationOpen = false;
|
if (mode === "Invite Only") registrationOpen = false;
|
||||||
if (mode === "Approval Required") requiresApproval = true;
|
requiresApproval = form.registrationApproval === "manual";
|
||||||
} else {
|
} else {
|
||||||
registrationOpen = false;
|
registrationOpen = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,16 @@ export function RadioGroup({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
options: { label: string; value: string }[];
|
options: { label: string; value: string }[];
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap", opacity: disabled ? 0.55 : 1 }}>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<label
|
<label
|
||||||
key={o.value}
|
key={o.value}
|
||||||
|
|
@ -77,7 +79,7 @@ export function RadioGroup({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "6px",
|
gap: "6px",
|
||||||
cursor: "pointer",
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
fontSize: "var(--text-sm)",
|
fontSize: "var(--text-sm)",
|
||||||
fontFamily: "var(--font-noto-sans)",
|
fontFamily: "var(--font-noto-sans)",
|
||||||
color: "var(--card-foreground)",
|
color: "var(--card-foreground)",
|
||||||
|
|
@ -88,8 +90,9 @@ export function RadioGroup({
|
||||||
name={name}
|
name={name}
|
||||||
value={o.value}
|
value={o.value}
|
||||||
checked={value === o.value}
|
checked={value === o.value}
|
||||||
|
disabled={disabled}
|
||||||
onChange={() => onChange(o.value)}
|
onChange={() => onChange(o.value)}
|
||||||
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: "pointer" }}
|
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: disabled ? "not-allowed" : "pointer" }}
|
||||||
/>
|
/>
|
||||||
{o.label}
|
{o.label}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ export interface EventForm {
|
||||||
endDate: string;
|
endDate: string;
|
||||||
registrationRequired: "Yes" | "No";
|
registrationRequired: "Yes" | "No";
|
||||||
registrationMode: string;
|
registrationMode: string;
|
||||||
|
/** Auto Confirm → confirmed; Manual Confirm → pending until approved. */
|
||||||
|
registrationApproval: "auto" | "manual";
|
||||||
verifyEmail: "Yes" | "No";
|
verifyEmail: "Yes" | "No";
|
||||||
registrationDeadline: string;
|
registrationDeadline: string;
|
||||||
authenticationType: string;
|
authenticationType: string;
|
||||||
|
|
@ -91,6 +93,7 @@ export const INITIAL: EventForm = {
|
||||||
endDate: "",
|
endDate: "",
|
||||||
registrationRequired: "No",
|
registrationRequired: "No",
|
||||||
registrationMode: "",
|
registrationMode: "",
|
||||||
|
registrationApproval: "auto",
|
||||||
verifyEmail: "Yes",
|
verifyEmail: "Yes",
|
||||||
registrationDeadline: "",
|
registrationDeadline: "",
|
||||||
authenticationType: "otp_email",
|
authenticationType: "otp_email",
|
||||||
|
|
@ -155,12 +158,16 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
|
||||||
let registrationRequired: "Yes" | "No" = "No";
|
let registrationRequired: "Yes" | "No" = "No";
|
||||||
let registrationMode = "";
|
let registrationMode = "";
|
||||||
const mode = st.registrationMode as string | undefined;
|
const mode = st.registrationMode as string | undefined;
|
||||||
|
const registrationApproval: "auto" | "manual" =
|
||||||
|
st.requiresApproval === true || mode === "Approval Required" ? "manual" : "auto";
|
||||||
|
|
||||||
if (mode === "Invite Only") {
|
if (mode === "Invite Only") {
|
||||||
registrationRequired = "Yes";
|
registrationRequired = "Yes";
|
||||||
registrationMode = "Invite Only";
|
registrationMode = "Invite Only";
|
||||||
} else if (mode === "Approval Required" || st.requiresApproval === true) {
|
} else if (mode === "Approval Required") {
|
||||||
|
// Legacy: approval moved to registrationApproval; normalize mode to Open
|
||||||
registrationRequired = "Yes";
|
registrationRequired = "Yes";
|
||||||
registrationMode = "Approval Required";
|
registrationMode = "Open";
|
||||||
} else if (mode === "Open" || st.registrationOpen === true) {
|
} else if (mode === "Open" || st.registrationOpen === true) {
|
||||||
registrationRequired = "Yes";
|
registrationRequired = "Yes";
|
||||||
registrationMode = "Open";
|
registrationMode = "Open";
|
||||||
|
|
@ -168,7 +175,7 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
|
||||||
registrationRequired = "No";
|
registrationRequired = "No";
|
||||||
} else if (st.registrationOpen || st.requiresApproval) {
|
} else if (st.registrationOpen || st.requiresApproval) {
|
||||||
registrationRequired = "Yes";
|
registrationRequired = "Yes";
|
||||||
registrationMode = st.requiresApproval ? "Approval Required" : "Open";
|
registrationMode = "Open";
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = (ev.metadata as Record<string, unknown>) || {};
|
const meta = (ev.metadata as Record<string, unknown>) || {};
|
||||||
|
|
@ -210,6 +217,7 @@ export function apiEventToForm(ev: Record<string, unknown>): EventForm {
|
||||||
endDate: toDateInput(ev.endDate as string),
|
endDate: toDateInput(ev.endDate as string),
|
||||||
registrationRequired,
|
registrationRequired,
|
||||||
registrationMode,
|
registrationMode,
|
||||||
|
registrationApproval,
|
||||||
verifyEmail: st.verifyEmail === false ? "No" : "Yes",
|
verifyEmail: st.verifyEmail === false ? "No" : "Yes",
|
||||||
registrationDeadline: st.registrationDeadline ? toDateInput(st.registrationDeadline as string) : "",
|
registrationDeadline: st.registrationDeadline ? toDateInput(st.registrationDeadline as string) : "",
|
||||||
authenticationType: apiAuthenticationTypeToForm(st.authenticationType),
|
authenticationType: apiAuthenticationTypeToForm(st.authenticationType),
|
||||||
|
|
@ -242,6 +250,7 @@ export function mergeActivitySettingsFormWithStored(form: EventForm, storedEvent
|
||||||
endDate: t(form.endDate) || d.endDate,
|
endDate: t(form.endDate) || d.endDate,
|
||||||
registrationRequired: form.registrationRequired,
|
registrationRequired: form.registrationRequired,
|
||||||
registrationMode: form.registrationMode || d.registrationMode,
|
registrationMode: form.registrationMode || d.registrationMode,
|
||||||
|
registrationApproval: form.registrationApproval || d.registrationApproval,
|
||||||
verifyEmail: form.verifyEmail,
|
verifyEmail: form.verifyEmail,
|
||||||
registrationDeadline: form.registrationDeadline,
|
registrationDeadline: form.registrationDeadline,
|
||||||
authenticationType: form.authenticationType || d.authenticationType,
|
authenticationType: form.authenticationType || d.authenticationType,
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ export function ParticipantBulkActionBar({
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
variant: ParticipantBulkVariant;
|
variant: ParticipantBulkVariant;
|
||||||
onAddTags: () => void;
|
onAddTags?: () => void;
|
||||||
onRemoveTags: () => void;
|
onRemoveTags?: () => void;
|
||||||
onAssignEvents?: () => void;
|
onAssignEvents?: () => void;
|
||||||
onNewList: () => void;
|
onNewList?: () => void;
|
||||||
onAddToList: () => void;
|
onAddToList?: () => void;
|
||||||
onDeleteSelected?: () => void;
|
onDeleteSelected?: () => void;
|
||||||
onRemoveFromEvent?: () => void;
|
onRemoveFromEvent?: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
|
|
@ -55,13 +55,13 @@ export function ParticipantBulkActionBar({
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>
|
||||||
{selectedCount} selected
|
{selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
<button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button>
|
{onAddTags ? <button type="button" disabled={busy} onClick={onAddTags} style={btnStyle}>Add tags</button> : null}
|
||||||
<button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button>
|
{onRemoveTags ? <button type="button" disabled={busy} onClick={onRemoveTags} style={btnStyle}>Remove tags</button> : null}
|
||||||
{variant === "portfolio" && onAssignEvents ? (
|
{variant === "portfolio" && onAssignEvents ? (
|
||||||
<button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button>
|
<button type="button" disabled={busy} onClick={onAssignEvents} style={btnStyle}>Assign to event(s)</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button>
|
{onNewList ? <button type="button" disabled={busy} onClick={onNewList} style={btnStyle}>New custom list</button> : null}
|
||||||
<button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button>
|
{onAddToList ? <button type="button" disabled={busy} onClick={onAddToList} style={btnStyle}>Add to list</button> : null}
|
||||||
{variant === "portfolio" && onDeleteSelected ? (
|
{variant === "portfolio" && onDeleteSelected ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export function ParticipantBulkDialogs({
|
||||||
onToggleTag,
|
onToggleTag,
|
||||||
availableTags,
|
availableTags,
|
||||||
removeTagOptions,
|
removeTagOptions,
|
||||||
|
tagScope = "global",
|
||||||
newListName,
|
newListName,
|
||||||
onNewListNameChange,
|
onNewListNameChange,
|
||||||
newListDesc,
|
newListDesc,
|
||||||
|
|
@ -37,6 +38,8 @@ export function ParticipantBulkDialogs({
|
||||||
onToggleTag: (tag: string) => void;
|
onToggleTag: (tag: string) => void;
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
removeTagOptions: string[];
|
removeTagOptions: string[];
|
||||||
|
/** Event page = tags stored on this event membership; global = org-wide root tags. */
|
||||||
|
tagScope?: "event" | "global";
|
||||||
newListName: string;
|
newListName: string;
|
||||||
onNewListNameChange: (v: string) => void;
|
onNewListNameChange: (v: string) => void;
|
||||||
newListDesc: string;
|
newListDesc: string;
|
||||||
|
|
@ -99,12 +102,26 @@ export function ParticipantBulkDialogs({
|
||||||
|
|
||||||
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
|
if (bulkDialog === "addTags" || bulkDialog === "removeTags") {
|
||||||
const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags;
|
const tagOptions = bulkDialog === "removeTags" ? removeTagOptions : availableTags;
|
||||||
const emptyMessage = bulkDialog === "removeTags" ? "No tags are currently assigned to the selected participants." : "No tags are available yet for this event.";
|
const emptyMessage =
|
||||||
|
bulkDialog === "removeTags"
|
||||||
|
? "No tags are currently assigned to the selected participants."
|
||||||
|
: tagScope === "event"
|
||||||
|
? "No tags are available yet for this event."
|
||||||
|
: "No tags are available yet for this organisation.";
|
||||||
|
const scopeHint =
|
||||||
|
tagScope === "event"
|
||||||
|
? bulkDialog === "addTags"
|
||||||
|
? "Tags apply to this event only (also shown on the person’s global record, not on other events)."
|
||||||
|
: "Removes tags from this event only. Organisation-wide tags of the same name stay visible here."
|
||||||
|
: bulkDialog === "addTags"
|
||||||
|
? "Tags apply organisation-wide and appear on every event for these people."
|
||||||
|
: "Removes the tag from the person entirely (organisation-wide and any event-specific copies).";
|
||||||
|
|
||||||
return overlay(
|
return overlay(
|
||||||
<>
|
<>
|
||||||
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
|
<h3 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-base)", margin: "0 0 8px" }}>{bulkDialog === "addTags" ? "Add tags" : "Remove tags"}</h3>
|
||||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 8px", fontFamily: "var(--font-noto-sans)" }}>Type a tag or select one below to apply to {selectedCount} selected participant(s).</p>
|
||||||
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", margin: "0 0 12px", fontFamily: "var(--font-noto-sans)" }}>{scopeHint}</p>
|
||||||
<input
|
<input
|
||||||
value={manualTagInput}
|
value={manualTagInput}
|
||||||
onChange={(e) => setManualTagInput(e.target.value)}
|
onChange={(e) => setManualTagInput(e.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { usePermission } from "../../contexts/PermissionContext";
|
||||||
import {
|
import {
|
||||||
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
|
Search, Plus, Download, Upload, Eye, Pencil, Trash2, Check, X,
|
||||||
Phone, Mail, Calendar, User, Filter, UserCheck,
|
Phone, Mail, Calendar, User, Filter, UserCheck,
|
||||||
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
|
AlertCircle, List, LayoutGrid, ChevronDown, CheckCircle2, Clock,
|
||||||
Users, UserX, ChevronLeft, ChevronRight, QrCode,
|
Users, ChevronLeft, ChevronRight, QrCode,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { participantsApi } from "../../../services/api";
|
import { participantsApi } from "../../../services/api";
|
||||||
import type { Participant as ApiParticipant } from "../../../services/api/types";
|
import type { Participant as ApiParticipant } from "../../../services/api/types";
|
||||||
|
|
@ -42,7 +43,7 @@ interface ParticipantDisplay {
|
||||||
function toDisplay(p: ApiParticipant): ParticipantDisplay {
|
function toDisplay(p: ApiParticipant): ParticipantDisplay {
|
||||||
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
|
const name = [p.firstName, p.lastName].filter(Boolean).join(" ").trim() || "—";
|
||||||
const regDate = p.registrationDate ? new Date(p.registrationDate).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) : null;
|
const regDate = p.registrationDate ? new Date(p.registrationDate).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }) : null;
|
||||||
const statusLabel = { pending: "Pending", confirmed: "Confirmed", checked_in: "Checked In", cancelled: "Cancelled" }[p.status] ?? p.status;
|
const statusLabel = { pending: "Pending", confirmed: "Confirmed", checked_in: "Checked In", cancelled: "Cancelled", rejected: "Rejected" }[p.status] ?? p.status;
|
||||||
return {
|
return {
|
||||||
id: p._id,
|
id: p._id,
|
||||||
name,
|
name,
|
||||||
|
|
@ -142,8 +143,18 @@ function CheckInBadge({ checked }: { checked: boolean }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ status }: { status: string }) {
|
function StatusDot({ status }: { status: string }) {
|
||||||
const color = status === "Cancelled" ? "var(--muted)" : status === "Checked In" ? "#15803d" : status === "Confirmed" ? "#2563eb" : "var(--card-foreground)";
|
const color =
|
||||||
const dotColor = status === "Cancelled" ? "#9ca3af" : status === "Checked In" ? "#16a34a" : status === "Confirmed" ? "#2563eb" : "#94a3b8";
|
status === "Rejected" ? "var(--destructive)"
|
||||||
|
: status === "Cancelled" ? "var(--muted)"
|
||||||
|
: status === "Checked In" ? "#15803d"
|
||||||
|
: status === "Confirmed" ? "#2563eb"
|
||||||
|
: "var(--card-foreground)";
|
||||||
|
const dotColor =
|
||||||
|
status === "Rejected" ? "#dc2626"
|
||||||
|
: status === "Cancelled" ? "#9ca3af"
|
||||||
|
: status === "Checked In" ? "#16a34a"
|
||||||
|
: status === "Confirmed" ? "#2563eb"
|
||||||
|
: "#94a3b8";
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontSize: "11px", fontFamily: "var(--font-noto-sans)", color }}>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: "4px", fontSize: "11px", fontFamily: "var(--font-noto-sans)", color }}>
|
||||||
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: dotColor, display: "block" }} />
|
<span style={{ width: "6px", height: "6px", borderRadius: "50%", background: dotColor, display: "block" }} />
|
||||||
|
|
@ -152,6 +163,20 @@ function StatusDot({ status }: { status: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const iconBtnStyle: React.CSSProperties = {
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
cursor: "pointer",
|
||||||
|
};
|
||||||
|
|
||||||
/* ─────────────── Filter Chip ─────────────── */
|
/* ─────────────── Filter Chip ─────────────── */
|
||||||
|
|
||||||
function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
|
function FilterSelect({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
|
||||||
|
|
@ -184,16 +209,19 @@ interface RowProps {
|
||||||
onView: () => void;
|
onView: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onToggleCheckIn: () => void;
|
onConfirm: () => void;
|
||||||
|
onReject: () => void;
|
||||||
onQr: () => void;
|
onQr: () => void;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onToggleSelect?: () => void;
|
onToggleSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) {
|
function ParticipantRow({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const isPending = p.status === "Pending";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -260,39 +288,46 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
|
||||||
{/* Actions — always visible */}
|
{/* Actions — always visible */}
|
||||||
<td style={{ padding: "12px 16px 12px 8px", whiteSpace: "nowrap" }}>
|
<td style={{ padding: "12px 16px 12px 8px", whiteSpace: "nowrap" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||||
{/* Quick check-in */}
|
{isPending && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCheckIn}
|
type="button"
|
||||||
title={p.checkedIn ? "Undo check-in" : "Mark checked in"}
|
onClick={onConfirm}
|
||||||
|
title="Confirm registration"
|
||||||
|
aria-label="Confirm registration"
|
||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: "4px",
|
display: "flex", alignItems: "center", gap: "4px",
|
||||||
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
||||||
background: p.checkedIn ? "var(--secondary)" : "#dcfce7",
|
background: "#dcfce7", border: "1px solid #86efac", color: "#15803d",
|
||||||
border: `1px solid ${p.checkedIn ? "var(--border)" : "#86efac"}`,
|
|
||||||
color: p.checkedIn ? "var(--muted)" : "#15803d",
|
|
||||||
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||||
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{p.checkedIn ? <X size={11} /> : <Check size={11} />}
|
<Check size={11} /> Confirm
|
||||||
{p.checkedIn ? "Undo" : "Check In"}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReject}
|
||||||
|
title="Reject registration"
|
||||||
|
aria-label="Reject registration"
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: "4px",
|
||||||
|
padding: "4px 10px", borderRadius: "var(--radius-button)",
|
||||||
|
background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c",
|
||||||
|
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||||
|
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} /> Reject
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onQr}
|
onClick={onQr}
|
||||||
title="Check-in QR code (EventBuzz Checkins app)"
|
title="Check-in QR code (EventBuzz Checkins app)"
|
||||||
style={{
|
aria-label="Check-in QR code"
|
||||||
display: "flex",
|
style={iconBtnStyle}
|
||||||
alignItems: "center",
|
|
||||||
gap: "4px",
|
|
||||||
padding: "4px 10px",
|
|
||||||
borderRadius: "var(--radius-button)",
|
|
||||||
background: "var(--card)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
color: "var(--card-foreground)",
|
|
||||||
fontSize: "var(--text-xs)",
|
|
||||||
fontFamily: "var(--font-noto-sans)",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||||
|
|
@ -302,32 +337,45 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<QrCode size={11} /> QR
|
<QrCode size={13} />
|
||||||
</button>
|
</button>
|
||||||
{/* View */}
|
<button
|
||||||
<button onClick={onView} title="View profile"
|
type="button"
|
||||||
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
|
onClick={onView}
|
||||||
|
title="View profile"
|
||||||
|
aria-label="View profile"
|
||||||
|
style={iconBtnStyle}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||||
>
|
>
|
||||||
<Eye size={11} /> View
|
<Eye size={13} />
|
||||||
</button>
|
</button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button onClick={onEdit} title="Edit participant"
|
<button
|
||||||
style={{ display: "flex", alignItems: "center", gap: "4px", padding: "4px 10px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", cursor: "pointer" }}
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
title="Edit participant"
|
||||||
|
aria-label="Edit participant"
|
||||||
|
style={iconBtnStyle}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--card-foreground)"; }}
|
||||||
>
|
>
|
||||||
<Pencil size={11} /> Edit
|
<Pencil size={13} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={onDelete} title="Delete participant"
|
{canDelete && (
|
||||||
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" }}
|
<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)"; }}
|
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)"; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "var(--card)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; }}
|
||||||
>
|
>
|
||||||
<Trash2 size={11} /> Delete
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -336,8 +384,9 @@ function ParticipantRow({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, ca
|
||||||
|
|
||||||
/* ─────────────── Participant Card ─────────────── */
|
/* ─────────────── Participant Card ─────────────── */
|
||||||
|
|
||||||
function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, canEdit, selectable, selected, onToggleSelect }: RowProps) {
|
function ParticipantCard({ p, onView, onEdit, onDelete, onConfirm, onReject, onQr, canEdit, canDelete, selectable, selected, onToggleSelect }: RowProps) {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const isPending = p.status === "Pending";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -371,22 +420,6 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
|
||||||
<StatusDot status={p.status} />
|
<StatusDot status={p.status} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Check-in quick toggle — always visible */}
|
|
||||||
<button
|
|
||||||
onClick={onToggleCheckIn}
|
|
||||||
title={p.checkedIn ? "Undo check-in" : "Mark as checked in"}
|
|
||||||
style={{
|
|
||||||
width: "28px", height: "28px", flexShrink: 0,
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: p.checkedIn ? "#dcfce7" : "var(--secondary)",
|
|
||||||
border: `1px solid ${p.checkedIn ? "#86efac" : "var(--border)"}`,
|
|
||||||
color: p.checkedIn ? "#15803d" : "var(--muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact details */}
|
{/* Contact details */}
|
||||||
|
|
@ -407,13 +440,48 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<div style={{ display: "flex", gap: "6px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
title="Confirm registration"
|
||||||
|
style={{
|
||||||
|
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "4px",
|
||||||
|
padding: "6px 8px", borderRadius: "var(--radius-button)",
|
||||||
|
background: "#dcfce7", border: "1px solid #86efac", color: "#15803d",
|
||||||
|
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||||
|
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={11} /> Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReject}
|
||||||
|
title="Reject registration"
|
||||||
|
style={{
|
||||||
|
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "4px",
|
||||||
|
padding: "6px 8px", borderRadius: "var(--radius-button)",
|
||||||
|
background: "#fef2f2", border: "1px solid #fecaca", color: "#b91c1c",
|
||||||
|
fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)",
|
||||||
|
cursor: "pointer", fontWeight: "var(--font-weight-medium)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} /> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer: check-in badge + actions */}
|
{/* Footer: check-in badge + actions */}
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "10px", borderTop: "1px solid var(--border)" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "10px", borderTop: "1px solid var(--border)" }}>
|
||||||
<CheckInBadge checked={p.checkedIn} />
|
<CheckInBadge checked={p.checkedIn} />
|
||||||
<div style={{ display: "flex", gap: "4px" }}>
|
<div style={{ display: "flex", gap: "4px" }}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onQr}
|
onClick={onQr}
|
||||||
title="Check-in QR code"
|
title="Check-in QR code"
|
||||||
|
aria-label="Check-in QR code"
|
||||||
style={{
|
style={{
|
||||||
width: "26px",
|
width: "26px",
|
||||||
height: "26px",
|
height: "26px",
|
||||||
|
|
@ -435,26 +503,28 @@ function ParticipantCard({ p, onView, onEdit, onDelete, onToggleCheckIn, onQr, c
|
||||||
>
|
>
|
||||||
<QrCode size={12} />
|
<QrCode size={12} />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onView} title="View" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
<button type="button" onClick={onView} title="View" aria-label="View profile" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||||
>
|
>
|
||||||
<Eye size={12} />
|
<Eye size={12} />
|
||||||
</button>
|
</button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button onClick={onEdit} title="Edit" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
<button type="button" onClick={onEdit} title="Edit" aria-label="Edit participant" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
||||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||||
>
|
>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={onDelete} title="Delete" style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--secondary)", border: "1px solid var(--border)", borderRadius: "var(--radius-button)", cursor: "pointer", color: "var(--muted)" }}
|
{canDelete && (
|
||||||
|
<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)"; }}
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
||||||
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}
|
||||||
>
|
>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -515,8 +585,8 @@ function ViewModal({ p, onClose, onEdit, canEdit }: { p: ParticipantDisplay; onC
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Pending", "Confirmed", "Checked In", "Cancelled"];
|
const STATUS_OPTIONS = ["Pending", "Confirmed", "Checked In", "Cancelled", "Rejected"];
|
||||||
const STATUS_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "cancelled" };
|
const STATUS_API_MAP: Record<string, string> = { "Pending": "pending", "Confirmed": "confirmed", "Checked In": "checked_in", "Cancelled": "cancelled", "Rejected": "rejected" };
|
||||||
const DESIGNATION_OPTIONS = ["Delegate", "Speaker", "Moderator", "Panelist"];
|
const DESIGNATION_OPTIONS = ["Delegate", "Speaker", "Moderator", "Panelist"];
|
||||||
|
|
||||||
/* ─────────────── Confirm Modal ─────────────── */
|
/* ─────────────── Confirm Modal ─────────────── */
|
||||||
|
|
@ -553,10 +623,14 @@ export type ParticipantsPageProps = {
|
||||||
export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) {
|
export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProps = {}) {
|
||||||
const { selectedEventId, setActiveNav } = useAppNav();
|
const { selectedEventId, setActiveNav } = useAppNav();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const canWriteParticipants = canModule("participants", "write");
|
||||||
|
const canDeleteParticipants = canModule("participants", "delete");
|
||||||
const listOrgId = portfolioListOrganizationId(user);
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
const isSuperAdmin = user?.role === "super_admin";
|
const isSuperAdmin = user?.role === "super_admin";
|
||||||
|
const canEditParticipants = canWriteParticipants || isSuperAdmin;
|
||||||
const enableBulk = !compactChrome;
|
const enableBulk = !compactChrome;
|
||||||
|
|
||||||
const [list, setList] = useState<ParticipantDisplay[]>([]);
|
const [list, setList] = useState<ParticipantDisplay[]>([]);
|
||||||
|
|
@ -678,7 +752,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||||
const checkedIn = list.filter(p => p.checkedIn).length;
|
const checkedIn = list.filter(p => p.checkedIn).length;
|
||||||
const speakers = list.filter(p => ["Speaker", "Moderator", "Panelist"].includes(p.role)).length;
|
|
||||||
|
|
||||||
const deleteParticipant = (id: string) => {
|
const deleteParticipant = (id: string) => {
|
||||||
const p = list.find(x => x.id === id);
|
const p = list.find(x => x.id === id);
|
||||||
|
|
@ -691,9 +764,20 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCheckIn = (id: string) => {
|
const confirmRegistration = (id: string) => {
|
||||||
if (!selectedEventId) return;
|
if (!selectedEventId) return;
|
||||||
participantsApi.checkIn(id, selectedEventId).then(() => fetchList());
|
participantsApi.confirm(id, selectedEventId).then((res) => {
|
||||||
|
if (res.error) setPageMsg(res.error);
|
||||||
|
else fetchList();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectRegistration = (id: string) => {
|
||||||
|
if (!selectedEventId) return;
|
||||||
|
participantsApi.reject(id, selectedEventId).then((res) => {
|
||||||
|
if (res.error) setPageMsg(res.error);
|
||||||
|
else fetchList();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportAllCsv = async () => {
|
const exportAllCsv = async () => {
|
||||||
|
|
@ -810,27 +894,6 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Stats strip ── */}
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "repeat(2, 1fr)" : "repeat(4, 1fr)", gap: isMobile ? "10px" : "12px" }}>
|
|
||||||
{[
|
|
||||||
{ icon: <Users size={14} />, label: "Total Registered", value: total, accent: "var(--chart-1)" },
|
|
||||||
{ icon: <UserCheck size={14} />, label: "Checked In (this page)", value: checkedIn, accent: "var(--chart-2)", sub: list.length ? `${Math.round((checkedIn / list.length) * 100)}%` : "" },
|
|
||||||
{ icon: <UserX size={14} />, label: "Not Checked In (this page)", value: list.length - checkedIn, accent: "var(--chart-3)" },
|
|
||||||
{ icon: <User size={14} />, label: "Speakers & Staff (this page)", value: speakers, accent: "var(--chart-4)" },
|
|
||||||
].map(stat => (
|
|
||||||
<div key={stat.label} style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "14px 16px", display: "flex", alignItems: "center", gap: "12px", borderLeft: `3px solid ${stat.accent}` }}>
|
|
||||||
<div style={{ width: "32px", height: "32px", borderRadius: "var(--radius-button)", background: stat.accent + "15", display: "flex", alignItems: "center", justifyContent: "center", color: stat.accent, flexShrink: 0 }}>
|
|
||||||
{stat.icon}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)", lineHeight: "1.1" }}>{stat.value}</p>
|
|
||||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "2px" }}>{stat.label}</p>
|
|
||||||
{stat.sub && <p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "#16a34a", marginTop: "3px" }}>{stat.sub}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Filter + search bar ── */}
|
{/* ── Filter + search bar ── */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
{/* Top row: search + filter toggle + view toggle */}
|
{/* Top row: search + filter toggle + view toggle */}
|
||||||
|
|
@ -906,9 +969,13 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
|
onRemoveTags={() => void bulk.openBulkDialog("removeTags")}
|
||||||
onNewList={() => void bulk.openBulkDialog("newList")}
|
onNewList={() => void bulk.openBulkDialog("newList")}
|
||||||
onAddToList={() => void bulk.openBulkDialog("addToList")}
|
onAddToList={() => void bulk.openBulkDialog("addToList")}
|
||||||
onRemoveFromEvent={() => {
|
onRemoveFromEvent={
|
||||||
|
canDeleteParticipants
|
||||||
|
? () => {
|
||||||
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
|
if (window.confirm(`Remove ${selectedIds.length} participant(s) from this event?`)) void bulk.runBulkRemoveFromEvent();
|
||||||
}}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -963,9 +1030,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
onView={() => setViewModal(p)}
|
onView={() => setViewModal(p)}
|
||||||
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
|
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
|
||||||
onDelete={() => deleteParticipant(p.id)}
|
onDelete={() => deleteParticipant(p.id)}
|
||||||
onToggleCheckIn={() => toggleCheckIn(p.id)}
|
onConfirm={() => confirmRegistration(p.id)}
|
||||||
|
onReject={() => rejectRegistration(p.id)}
|
||||||
onQr={() => setQrParticipant(p)}
|
onQr={() => setQrParticipant(p)}
|
||||||
canEdit={isSuperAdmin}
|
canEdit={canEditParticipants}
|
||||||
|
canDelete={canDeleteParticipants}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -1001,9 +1070,11 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
onView={() => setViewModal(p)}
|
onView={() => setViewModal(p)}
|
||||||
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
|
onEdit={() => { setEditId(p.id); setShowAddPanel(true); }}
|
||||||
onDelete={() => deleteParticipant(p.id)}
|
onDelete={() => deleteParticipant(p.id)}
|
||||||
onToggleCheckIn={() => toggleCheckIn(p.id)}
|
onConfirm={() => confirmRegistration(p.id)}
|
||||||
|
onReject={() => rejectRegistration(p.id)}
|
||||||
onQr={() => setQrParticipant(p)}
|
onQr={() => setQrParticipant(p)}
|
||||||
canEdit={isSuperAdmin}
|
canEdit={canEditParticipants}
|
||||||
|
canDelete={canDeleteParticipants}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1041,7 +1112,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
p={viewModal}
|
p={viewModal}
|
||||||
onClose={() => setViewModal(null)}
|
onClose={() => setViewModal(null)}
|
||||||
onEdit={() => { setEditId(viewModal.id); setViewModal(null); setShowAddPanel(true); }}
|
onEdit={() => { setEditId(viewModal.id); setViewModal(null); setShowAddPanel(true); }}
|
||||||
canEdit={isSuperAdmin}
|
canEdit={canEditParticipants}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{qrParticipant && (
|
{qrParticipant && (
|
||||||
|
|
@ -1097,6 +1168,7 @@ export function ParticipantsPage({ compactChrome = false }: ParticipantsPageProp
|
||||||
onToggleTag={bulk.toggleSelectedTag}
|
onToggleTag={bulk.toggleSelectedTag}
|
||||||
availableTags={tagOptions}
|
availableTags={tagOptions}
|
||||||
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
removeTagOptions={Array.from(new Set(list.filter((participant) => selectedIds.includes(participant.id)).flatMap((participant) => participant.tags)))}
|
||||||
|
tagScope={selectedEventId ? "event" : "global"}
|
||||||
newListName={bulk.newListName}
|
newListName={bulk.newListName}
|
||||||
onNewListNameChange={bulk.setNewListName}
|
onNewListNameChange={bulk.setNewListName}
|
||||||
newListDesc={bulk.newListDesc}
|
newListDesc={bulk.newListDesc}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,12 @@ export function useParticipantBulkActions(options: {
|
||||||
const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean);
|
const tags = (overrideTags?.length ? overrideTags : selectedTagValues).filter(Boolean);
|
||||||
if (!tags.length) return;
|
if (!tags.length) return;
|
||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
const res = await participantsApi.bulk({ action, participantIds: selectedIds, tags });
|
const res = await participantsApi.bulk({
|
||||||
|
action,
|
||||||
|
participantIds: selectedIds,
|
||||||
|
tags,
|
||||||
|
...(eventId ? { eventId } : {}),
|
||||||
|
});
|
||||||
setBulkBusy(false);
|
setBulkBusy(false);
|
||||||
if (res.error) onMessage(res.error);
|
if (res.error) onMessage(res.error);
|
||||||
else {
|
else {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
import React, { useState, useMemo } from "react";
|
import React, { useState, useMemo, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Search, X, ArrowRight, MapPin, Calendar, Users,
|
Search, X, ArrowRight, MapPin, Calendar, Users,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
doSearch, ALL_EVENTS, ALL_PAGES, ALL_ACTIONS,
|
doSearch, ALL_PAGES, ALL_ACTIONS,
|
||||||
STATUS_BADGE, LOC_ICON,
|
STATUS_BADGE, LOC_ICON,
|
||||||
|
mapApiEventToSearchEvent,
|
||||||
SearchEvent, SearchPage, SearchAction,
|
SearchEvent, SearchPage, SearchAction,
|
||||||
} from "./searchIndex";
|
} from "./searchIndex";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { eventsApi } from "../../../services/api";
|
||||||
|
import { portfolioListOrganizationId } from "../../../services/api/scopedOrganization";
|
||||||
|
|
||||||
/* ─────────────────────────────────────────
|
/* ─────────────────────────────────────────
|
||||||
Props
|
Props
|
||||||
|
|
@ -230,8 +234,36 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
|
||||||
const [activeTab, setActiveTab] = useState<FilterTab>("all");
|
const [activeTab, setActiveTab] = useState<FilterTab>("all");
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
|
const { user } = useAuth();
|
||||||
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
|
const [orgSearchEvents, setOrgSearchEvents] = useState<SearchEvent[]>([]);
|
||||||
|
|
||||||
const results = useMemo(() => doSearch(query), [query]);
|
useEffect(() => {
|
||||||
|
if (!listOrgId) {
|
||||||
|
setOrgSearchEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const res = await eventsApi.list({
|
||||||
|
organization: listOrgId,
|
||||||
|
recordKind: "event",
|
||||||
|
page: 1,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.error || !res.data?.events) {
|
||||||
|
setOrgSearchEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOrgSearchEvents(res.data.events.map(mapApiEventToSearchEvent));
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [listOrgId]);
|
||||||
|
|
||||||
|
const results = useMemo(() => doSearch(query, orgSearchEvents), [query, orgSearchEvents]);
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
|
|
||||||
/* Filter tab data */
|
/* Filter tab data */
|
||||||
|
|
@ -312,9 +344,14 @@ export function SearchResultsPage({ query, onQueryChange, onNavigate, onEventSel
|
||||||
Recent Events
|
Recent Events
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||||
{ALL_EVENTS.slice(0, 5).map(e => (
|
{orgSearchEvents.slice(0, 5).map(e => (
|
||||||
<EventCard key={e.id} event={e} query="" onClick={() => handleEventClick(e)} isActive={false} />
|
<EventCard key={e.id} event={e} query="" onClick={() => handleEventClick(e)} isActive={false} />
|
||||||
))}
|
))}
|
||||||
|
{orgSearchEvents.length === 0 && (
|
||||||
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
||||||
|
No events in this organisation yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,21 +44,58 @@ export const LOC_ICON: Record<LocationType, React.ReactNode> = {
|
||||||
Hybrid: <Zap size={10} />,
|
Hybrid: <Zap size={10} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Events ─── */
|
/* ─── Events (populated at runtime from current-org API; no cross-org demo data) ─── */
|
||||||
export const ALL_EVENTS: SearchEvent[] = [
|
export const ALL_EVENTS: SearchEvent[] = [];
|
||||||
{ id: "e1", title: "Napcon Jaipur 2026", subtitle: "27th National Conference on Pulmonary Diseases", status: "Live", type: "Conference", location: "Birla Auditorium, Jaipur", locationType: "Physical", startDate: "Nov 23, 2025", endDate: "Nov 26, 2025", participants: 26, sessions: 12 },
|
|
||||||
{ id: "e2", title: "Napcon Mumbai 2025", subtitle: "26th National Conference on Pulmonary Diseases", status: "Completed", type: "Conference", location: "NSCI Dome, Mumbai", locationType: "Physical", startDate: "Oct 12, 2025", endDate: "Oct 15, 2025", participants: 342, sessions: 28 },
|
function formatShortDate(iso: string | undefined): string {
|
||||||
{ id: "e3", title: "CHEST India 2026", subtitle: "Annual Indian CHEST Conference", status: "Upcoming", type: "Conference", location: "Taj Palace, New Delhi", locationType: "Physical", startDate: "Mar 15, 2026", endDate: "Mar 18, 2026", participants: 0, sessions: 0 },
|
if (!iso) return "";
|
||||||
{ id: "e4", title: "ICS Annual Conference 2026", subtitle: "Indian Chest Society Annual Scientific Conference", status: "Upcoming", type: "Conference", location: "Hotel Lalit, Bengaluru", locationType: "Hybrid", startDate: "Jun 10, 2026", endDate: "Jun 12, 2026", participants: 0, sessions: 0 },
|
const d = new Date(iso);
|
||||||
{ id: "e5", title: "COPD Management Workshop", subtitle: "Evidence-based COPD Treatment Protocols", status: "Completed", type: "Workshop", location: "AIIMS, New Delhi", locationType: "Physical", startDate: "Sep 05, 2025", endDate: "Sep 05, 2025", participants: 48, sessions: 6 },
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
{ id: "e6", title: "Interventional Pulmonology", subtitle: "Advanced Bronchoscopy & Intervention Training", status: "Completed", type: "Workshop", location: "PGI Chandigarh", locationType: "Physical", startDate: "Nov 28, 2025", endDate: "Nov 29, 2025", participants: 22, sessions: 8 },
|
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
|
||||||
{ id: "e7", title: "Respiratory Physiology Lab", subtitle: "Masterclass: PFT & Interpretation", status: "Upcoming", type: "Workshop", location: "Online", locationType: "Virtual", startDate: "Apr 10, 2026", endDate: "Apr 10, 2026", participants: 0, sessions: 0 },
|
}
|
||||||
{ id: "e8", title: "IMA Annual Summit 2025", subtitle: "Indian Medical Association National Congress", status: "Completed", type: "Summit", location: "Vigyan Bhavan, New Delhi", locationType: "Physical", startDate: "Aug 20, 2025", endDate: "Aug 22, 2025", participants: 520, sessions: 18 },
|
|
||||||
{ id: "e9", title: "AIIMS Research Conclave 2026",subtitle: "National Medical Research & Innovation Summit", status: "Upcoming", type: "Summit", location: "AIIMS Auditorium, New Delhi", locationType: "Physical", startDate: "Jul 15, 2026", endDate: "Jul 17, 2026", participants: 0, sessions: 0 },
|
function toEventStatus(raw: string | undefined): EventStatus {
|
||||||
{ id: "e10", title: "Monthly CME – December 2025", subtitle: "Asthma & Allergy Management Update", status: "Upcoming", type: "CME", location: "Online", locationType: "Virtual", startDate: "Dec 20, 2025", endDate: "Dec 20, 2025", participants: 0, sessions: 0 },
|
const k = String(raw || "draft").toLowerCase();
|
||||||
{ id: "e11", title: "Monthly CME – November 2025", subtitle: "COPD Exacerbations: Case Studies", status: "Completed", type: "CME", location: "Online", locationType: "Virtual", startDate: "Nov 20, 2025", endDate: "Nov 20, 2025", participants: 65, sessions: 4 },
|
if (k === "live") return "Live";
|
||||||
{ id: "e12", title: "Monthly CME – October 2025", subtitle: "Sleep Apnoea: Diagnosis & Management", status: "Completed", type: "CME", location: "Online", locationType: "Virtual", startDate: "Oct 18, 2025", endDate: "Oct 18, 2025", participants: 72, sessions: 4 },
|
if (k === "upcoming") return "Upcoming";
|
||||||
];
|
if (k === "completed") return "Completed";
|
||||||
|
return "Draft";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocationType(raw: string | undefined): LocationType {
|
||||||
|
const k = String(raw || "physical").toLowerCase();
|
||||||
|
if (k === "virtual") return "Virtual";
|
||||||
|
if (k === "hybrid") return "Hybrid";
|
||||||
|
return "Physical";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a dashboard Event API record into the search index shape. */
|
||||||
|
export function mapApiEventToSearchEvent(ev: {
|
||||||
|
_id: string;
|
||||||
|
name?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
location?: string;
|
||||||
|
locationType?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}): SearchEvent {
|
||||||
|
return {
|
||||||
|
id: String(ev._id),
|
||||||
|
title: String(ev.name || "Untitled event"),
|
||||||
|
subtitle: String(ev.subtitle || ev.description || "").slice(0, 160),
|
||||||
|
status: toEventStatus(ev.status),
|
||||||
|
type: String(ev.type || "Conference"),
|
||||||
|
location: String(ev.location || (ev.locationType === "virtual" ? "Online" : "")),
|
||||||
|
locationType: toLocationType(ev.locationType),
|
||||||
|
startDate: formatShortDate(ev.startDate),
|
||||||
|
endDate: formatShortDate(ev.endDate),
|
||||||
|
participants: 0,
|
||||||
|
sessions: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Pages ─── */
|
/* ─── Pages ─── */
|
||||||
export const ALL_PAGES: SearchPage[] = [
|
export const ALL_PAGES: SearchPage[] = [
|
||||||
|
|
@ -113,13 +150,13 @@ export const SUGGESTED_SHORTCUTS: { label: string; navId: string; icon: React.Re
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ─── Search function ─── */
|
/* ─── Search function ─── */
|
||||||
export function doSearch(query: string): SearchResults {
|
export function doSearch(query: string, orgEvents: SearchEvent[] = ALL_EVENTS): SearchResults {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return { events: [], pages: [], actions: [], total: 0 };
|
if (!q) return { events: [], pages: [], actions: [], total: 0 };
|
||||||
|
|
||||||
const match = (...strs: string[]) => strs.some(s => s.toLowerCase().includes(q));
|
const match = (...strs: string[]) => strs.some(s => s.toLowerCase().includes(q));
|
||||||
|
|
||||||
const events = ALL_EVENTS.filter(e =>
|
const events = orgEvents.filter(e =>
|
||||||
match(e.title, e.subtitle, e.location, e.type, e.status));
|
match(e.title, e.subtitle, e.location, e.type, e.status));
|
||||||
const pages = ALL_PAGES.filter(p =>
|
const pages = ALL_PAGES.filter(p =>
|
||||||
match(p.title, p.section, p.description));
|
match(p.title, p.section, p.description));
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,106 @@ function parseDatetimeLocal(s: string): Date | undefined {
|
||||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDateOnly(s: string): Date | undefined {
|
||||||
|
const raw = s.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||||||
|
const [y, m, d] = raw.split("-").map(Number);
|
||||||
|
const date = new Date(y, m - 1, d);
|
||||||
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||||
|
}
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local calendar date as `YYYY-MM-DD`. */
|
||||||
|
export function toDateOnlyValue(isoOrDate: string | Date | undefined): string {
|
||||||
|
if (!isoOrDate) return "";
|
||||||
|
const d = typeof isoOrDate === "string" ? parseDateOnly(isoOrDate) : isoOrDate;
|
||||||
|
if (!d || Number.isNaN(d.getTime())) return "";
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlatpickrDateInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date-only picker (Flatpickr) that reads/writes `YYYY-MM-DD`. */
|
||||||
|
export function FlatpickrDateInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
style,
|
||||||
|
disabled,
|
||||||
|
placeholder = "Select date…",
|
||||||
|
hasError,
|
||||||
|
}: FlatpickrDateInputProps) {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const fpRef = React.useRef<FlatpickrHandle | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const fp = flatpickr(el, {
|
||||||
|
enableTime: false,
|
||||||
|
dateFormat: "Y-m-d",
|
||||||
|
allowInput: true,
|
||||||
|
defaultDate: parseDateOnly(value),
|
||||||
|
onChange: dates => {
|
||||||
|
const d = dates[0];
|
||||||
|
onChange(d ? toDateOnlyValue(d) : "");
|
||||||
|
},
|
||||||
|
}) as FlatpickrHandle;
|
||||||
|
fpRef.current = fp;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fp.destroy();
|
||||||
|
fpRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init once
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fp = fpRef.current;
|
||||||
|
if (!fp) return;
|
||||||
|
|
||||||
|
const selected = fp.selectedDates[0];
|
||||||
|
const cur = selected ? toDateOnlyValue(selected) : "";
|
||||||
|
if (value === cur) return;
|
||||||
|
|
||||||
|
const d = parseDateOnly(value);
|
||||||
|
if (d) fp.setDate(d, false);
|
||||||
|
else fp.clear(false);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fp = fpRef.current;
|
||||||
|
if (!fp) return;
|
||||||
|
const input = fp.input as HTMLInputElement;
|
||||||
|
input.disabled = !!disabled;
|
||||||
|
fp.set("clickOpens", !disabled);
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={false}
|
||||||
|
autoComplete="off"
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
...(hasError ? { borderColor: "var(--destructive)" } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface FlatpickrDatetimeInputProps {
|
export interface FlatpickrDatetimeInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,16 @@ interface PermissionContextValue {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_IMPLIES: Record<AclAction, AclAction[]> = {
|
/** Higher privileges grant lower ones: delete ⇒ write ⇒ read (matches backend actionsInclude). */
|
||||||
read: ["read"],
|
function actionsInclude(entryActions: string[], action: AclAction): boolean {
|
||||||
write: ["read", "write"],
|
if (!Array.isArray(entryActions)) return false;
|
||||||
delete: ["read", "write", "delete"],
|
if (entryActions.includes(action)) return true;
|
||||||
};
|
if (action === "read" && (entryActions.includes("write") || entryActions.includes("delete"))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action === "write" && entryActions.includes("delete")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const PermissionContext = createContext<PermissionContextValue | null>(null);
|
const PermissionContext = createContext<PermissionContextValue | null>(null);
|
||||||
|
|
||||||
|
|
@ -29,7 +34,7 @@ export function PermissionProvider({ children }: { children: React.ReactNode })
|
||||||
if (!permissions) return user?.role === "super_admin";
|
if (!permissions) return user?.role === "super_admin";
|
||||||
if (permissions.isOrgSuperAdmin) return true;
|
if (permissions.isOrgSuperAdmin) return true;
|
||||||
const actions = permissions.modules?.[moduleKey] || [];
|
const actions = permissions.modules?.[moduleKey] || [];
|
||||||
return (ACTION_IMPLIES[action] || [action]).some((a) => actions.includes(a));
|
return actionsInclude(actions, action);
|
||||||
},
|
},
|
||||||
[permissions, user?.role],
|
[permissions, user?.role],
|
||||||
);
|
);
|
||||||
|
|
@ -72,6 +77,8 @@ export const NAV_MODULE_MAP: Record<string, string> = {
|
||||||
activities: "live_polls",
|
activities: "live_polls",
|
||||||
faculty: "speaker_management",
|
faculty: "speaker_management",
|
||||||
"all-participants": "participants",
|
"all-participants": "participants",
|
||||||
|
"participant-lists": "participants",
|
||||||
|
attendance: "attendance",
|
||||||
forms: "forms",
|
forms: "forms",
|
||||||
"forms-feedback": "forms",
|
"forms-feedback": "forms",
|
||||||
"forms-registration": "forms",
|
"forms-registration": "forms",
|
||||||
|
|
@ -85,7 +92,6 @@ export const NAV_MODULE_MAP: Record<string, string> = {
|
||||||
payment: "billing",
|
payment: "billing",
|
||||||
"user-management": "user_management",
|
"user-management": "user_management",
|
||||||
"roles-management": "user_management",
|
"roles-management": "user_management",
|
||||||
"participant-lists": "participants",
|
|
||||||
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
|
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
|
||||||
settings: "settings",
|
settings: "settings",
|
||||||
design: "custom_badge_and_print_file_creation",
|
design: "custom_badge_and_print_file_creation",
|
||||||
|
|
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,12 @@ function formatDate(iso?: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAdminName(admin: OrganizationRecord["primaryAdmin"]) {
|
||||||
|
if (!admin) return "";
|
||||||
|
const name = [admin.firstName, admin.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return name || "";
|
||||||
|
}
|
||||||
|
|
||||||
export function SuperAdminClientsPage() {
|
export function SuperAdminClientsPage() {
|
||||||
const [rows, setRows] = useState<OrganizationRecord[]>([]);
|
const [rows, setRows] = useState<OrganizationRecord[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
@ -220,7 +226,7 @@ export function SuperAdminClientsPage() {
|
||||||
<input
|
<input
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
placeholder="Search by name or email"
|
placeholder="Search by org name, email, or admin user"
|
||||||
style={{ ...inputStyle, flex: "1 1 220px", maxWidth: "360px" }}
|
style={{ ...inputStyle, flex: "1 1 220px", maxWidth: "360px" }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|
@ -252,7 +258,8 @@ export function SuperAdminClientsPage() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: "var(--accent)", textAlign: "left" }}>
|
<tr style={{ background: "var(--accent)", textAlign: "left" }}>
|
||||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Name</th>
|
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Name</th>
|
||||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Email</th>
|
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Org email</th>
|
||||||
|
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Associated user</th>
|
||||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Plan</th>
|
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Plan</th>
|
||||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Status</th>
|
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Status</th>
|
||||||
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Users</th>
|
<th style={{ padding: "10px 12px", fontWeight: 600 }}>Users</th>
|
||||||
|
|
@ -263,17 +270,39 @@ export function SuperAdminClientsPage() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} style={{ padding: "24px 16px", color: "var(--muted)", textAlign: "center" }}>
|
<td colSpan={8} style={{ padding: "24px 16px", color: "var(--muted)", textAlign: "center" }}>
|
||||||
{search
|
{search
|
||||||
? "No organizations match your search."
|
? "No organizations match your search."
|
||||||
: "No organizations yet. Use “Add organization” to create a tenant and its first super admin user."}
|
: "No organizations yet. Use “Add organization” to create a tenant and its first super admin user."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
rows.map((org) => (
|
rows.map((org) => {
|
||||||
|
const admin = org.primaryAdmin;
|
||||||
|
const adminName = formatAdminName(admin);
|
||||||
|
return (
|
||||||
<tr key={org._id} style={{ borderTop: "1px solid var(--border)" }}>
|
<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(--foreground)" }}>{org.name}</td>
|
||||||
<td style={{ padding: "10px 12px", color: "var(--muted)" }}>{org.email || "—"}</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?.plan ?? "free"}</td>
|
||||||
<td style={{ padding: "10px 12px" }}>{org.subscription?.status ?? "—"}</td>
|
<td style={{ padding: "10px 12px" }}>{org.subscription?.status ?? "—"}</td>
|
||||||
<td style={{ padding: "10px 12px" }}>{org.userCount ?? "—"}</td>
|
<td style={{ padding: "10px 12px" }}>{org.userCount ?? "—"}</td>
|
||||||
|
|
@ -296,7 +325,8 @@ export function SuperAdminClientsPage() {
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
|
||||||
pending: { bg: '#fef3c7', color: '#b45309' },
|
pending: { bg: '#fef3c7', color: '#b45309' },
|
||||||
confirmed: { bg: '#dcfce7', color: '#16a34a' },
|
confirmed: { bg: '#dcfce7', color: '#16a34a' },
|
||||||
checked_in: { bg: '#dbeafe', color: '#2563eb' },
|
checked_in: { bg: '#dbeafe', color: '#2563eb' },
|
||||||
|
rejected: { bg: '#fee2e2', color: '#dc2626' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLE_STYLES: Record<string, { bg: string; color: string }> = {
|
export const ROLE_STYLES: Record<string, { bg: string; color: string }> = {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export type EventType = (typeof EVENT_TYPES)[number];
|
||||||
export const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'] as const;
|
export const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'] as const;
|
||||||
export type EventStatus = (typeof EVENT_STATUS)[number];
|
export type EventStatus = (typeof EVENT_STATUS)[number];
|
||||||
|
|
||||||
export const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled'] as const;
|
export const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled', 'rejected'] as const;
|
||||||
export type ParticipantStatus = (typeof PARTICIPANT_STATUS)[number];
|
export type ParticipantStatus = (typeof PARTICIPANT_STATUS)[number];
|
||||||
|
|
||||||
export const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'] as const;
|
export const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'] as const;
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@ export interface BadgeCustomImagePayload {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BadgeBackgroundImageLayout {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateBadgeBatchPayload {
|
export interface CreateBadgeBatchPayload {
|
||||||
name: string;
|
name: string;
|
||||||
organization?: string;
|
organization?: string;
|
||||||
|
|
@ -61,6 +68,7 @@ export interface CreateBadgeBatchPayload {
|
||||||
participantListId?: string;
|
participantListId?: string;
|
||||||
backgroundMode: 'image' | 'color';
|
backgroundMode: 'image' | 'color';
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
|
backgroundImageLayout?: BadgeBackgroundImageLayout;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
fields: string[];
|
fields: string[];
|
||||||
layout?: BadgeLayoutElementPayload[];
|
layout?: BadgeLayoutElementPayload[];
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ export const eventsApi = {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
get(id: string) {
|
get(id: string, opts?: { enrich?: boolean }) {
|
||||||
return api.get<Event>(`/events/${id}`);
|
const q = opts?.enrich ? '?enrich=1' : '';
|
||||||
|
return api.get<Event>(`/events/${id}${q}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
create(payload: Partial<Event>) {
|
create(payload: Partial<Event>) {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export { formsApi } from './forms';
|
||||||
export { rolesApi } from './roles';
|
export { rolesApi } from './roles';
|
||||||
export { usersApi } from './users';
|
export { usersApi } from './users';
|
||||||
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
|
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
|
||||||
export { sessionsApi, type ApiSession, type SessionPayload } from './sessions';
|
export { sessionsApi, type ApiSession, type SessionPayload, type SessionTopic } from './sessions';
|
||||||
export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff';
|
export { eventStaffApi, type EventStaffMember, type AgendaTrack } from './eventStaff';
|
||||||
export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
|
export { quizPollsApi, type QuizPoll, type QuizPollPayload, type QuizPollQuestion } from './quizPolls';
|
||||||
export {
|
export {
|
||||||
|
|
@ -79,6 +79,7 @@ export {
|
||||||
type MessagingCampaign,
|
type MessagingCampaign,
|
||||||
type CampaignStatus,
|
type CampaignStatus,
|
||||||
type CampaignContent,
|
type CampaignContent,
|
||||||
|
type CampaignRecipients,
|
||||||
type WhatsAppQueueJobSummary,
|
type WhatsAppQueueJobSummary,
|
||||||
} from './campaigns';
|
} from './campaigns';
|
||||||
export { subscriptionApi, loadRazorpayScript } from './subscription';
|
export { subscriptionApi, loadRazorpayScript } from './subscription';
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ interface ListAllParams {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
q?: string;
|
q?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
/** When true, list soft-deleted participants only. */
|
||||||
|
deleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const participantsApi = {
|
export const participantsApi = {
|
||||||
|
|
@ -37,6 +39,7 @@ export const participantsApi = {
|
||||||
sp.set('limit', String(params?.limit ?? 20));
|
sp.set('limit', String(params?.limit ?? 20));
|
||||||
if (params?.q?.trim()) sp.set('q', params.q.trim());
|
if (params?.q?.trim()) sp.set('q', params.q.trim());
|
||||||
if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
|
if (params?.tag?.trim()) sp.set('tag', params.tag.trim());
|
||||||
|
if (params?.deleted) sp.set('deleted', 'true');
|
||||||
const query = sp.toString();
|
const query = sp.toString();
|
||||||
return api.get<{ participants: ParticipantWithEvent[]; total: number; page: number; limit: number }>(
|
return api.get<{ participants: ParticipantWithEvent[]; total: number; page: number; limit: number }>(
|
||||||
`/participants${query ? `?${query}` : ''}`
|
`/participants${query ? `?${query}` : ''}`
|
||||||
|
|
@ -127,23 +130,39 @@ export const participantsApi = {
|
||||||
return api.put<Participant>(`/participants/${id}`, payload);
|
return api.put<Participant>(`/participants/${id}`, payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Soft-delete participant (or remove from event via eventId — last membership soft-deletes). */
|
||||||
remove(id: string, eventId: string) {
|
remove(id: string, eventId: string) {
|
||||||
const q = new URLSearchParams({ event_id: eventId });
|
const q = new URLSearchParams({ event_id: eventId });
|
||||||
return api.delete<unknown>(`/participants/${id}?${q.toString()}`);
|
return api.delete<unknown>(`/participants/${id}?${q.toString()}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Remove participant record entirely (all events). */
|
/** Soft-delete participant record (all events). */
|
||||||
deleteOne(id: string) {
|
deleteOne(id: string) {
|
||||||
return api.delete<unknown>(`/participants/${id}`);
|
return api.delete<unknown>(`/participants/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Restore a soft-deleted participant. */
|
||||||
|
restore(id: string) {
|
||||||
|
return api.post<Participant>(`/participants/${id}/restore`, {});
|
||||||
|
},
|
||||||
|
|
||||||
checkIn(id: string, eventId: string) {
|
checkIn(id: string, eventId: string) {
|
||||||
const q = new URLSearchParams({ event_id: eventId });
|
const q = new URLSearchParams({ event_id: eventId });
|
||||||
return api.post<Participant>(`/participants/${id}/check-in?${q.toString()}`, {});
|
return api.post<Participant>(`/participants/${id}/check-in?${q.toString()}`, {});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
confirm(id: string, eventId: string) {
|
||||||
|
const q = new URLSearchParams({ event_id: eventId });
|
||||||
|
return api.post<Participant>(`/participants/${id}/confirm?${q.toString()}`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
reject(id: string, eventId: string) {
|
||||||
|
const q = new URLSearchParams({ event_id: eventId });
|
||||||
|
return api.post<Participant>(`/participants/${id}/reject?${q.toString()}`, {});
|
||||||
|
},
|
||||||
|
|
||||||
/** Full event export (all participants). Triggers browser download. */
|
/** Full event export (all participants). Triggers browser download. */
|
||||||
async downloadExport(eventId: string, fileName = 'participants-export.csv') {
|
async downloadExport(eventId: string, fileName?: string) {
|
||||||
const res = await fetchWithAuth(`/events/${eventId}/participants/export`, { method: 'GET' });
|
const res = await fetchWithAuth(`/events/${eventId}/participants/export`, { method: 'GET' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = res.statusText;
|
let msg = res.statusText;
|
||||||
|
|
@ -155,11 +174,12 @@ export const participantsApi = {
|
||||||
}
|
}
|
||||||
return { ok: false as const, error: msg };
|
return { ok: false as const, error: msg };
|
||||||
}
|
}
|
||||||
|
const fromHeader = parseContentDispositionFilename(res.headers.get('Content-Disposition'));
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = fileName;
|
a.download = fileName || fromHeader || 'Event - Participants.csv';
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
|
|
@ -224,3 +244,20 @@ export const participantsApi = {
|
||||||
}>('/participants/import', payload);
|
}>('/participants/import', payload);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function parseContentDispositionFilename(header: string | null): string | null {
|
||||||
|
if (!header) return null;
|
||||||
|
const star = /filename\*=UTF-8''([^;\n]+)/i.exec(header);
|
||||||
|
if (star?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(star[1].trim());
|
||||||
|
} catch {
|
||||||
|
/* fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const quoted = /filename="([^"]+)"/i.exec(header);
|
||||||
|
if (quoted?.[1]) return quoted[1];
|
||||||
|
const bare = /filename=([^;\n]+)/i.exec(header);
|
||||||
|
if (bare?.[1]) return bare[1].trim().replace(/^"|"$/g, '');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,15 @@ export interface ApiSession {
|
||||||
enableQnA?: boolean;
|
enableQnA?: boolean;
|
||||||
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
|
/** When false, hide Quiz & Polls tab on activity detail (default true for normal events). */
|
||||||
enableQnP?: boolean;
|
enableQnP?: boolean;
|
||||||
/** Mongo id of portfolio feedback form, or omit/null for default attendee UI */
|
/** Nested session topics for hierarchical programmes */
|
||||||
audienceFeedbackForm?: string | null;
|
topics?: SessionTopic[];
|
||||||
audienceRatingsForm?: string | null;
|
}
|
||||||
|
|
||||||
|
export interface SessionTopic {
|
||||||
|
type?: string;
|
||||||
|
duration?: string;
|
||||||
|
title?: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionPayload = {
|
export type SessionPayload = {
|
||||||
|
|
@ -59,6 +65,7 @@ export type SessionPayload = {
|
||||||
audienceRatingsForm?: string | null;
|
audienceRatingsForm?: string | null;
|
||||||
enableQnA?: boolean;
|
enableQnA?: boolean;
|
||||||
enableQnP?: boolean;
|
enableQnP?: boolean;
|
||||||
|
topics?: SessionTopic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sessionsApi = {
|
export const sessionsApi = {
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,23 @@ export interface User {
|
||||||
assignedEvents?: string[];
|
assignedEvents?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Primary org admin (super_admin) on platform-admin org list/detail */
|
||||||
|
export interface OrganizationPrimaryAdmin {
|
||||||
|
_id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OrganizationRecord {
|
export interface OrganizationRecord {
|
||||||
_id: string;
|
_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** Populated by platform-admin org list/detail */
|
/** Populated by platform-admin org list/detail */
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
|
/** First org super_admin user associated with this tenant */
|
||||||
|
primaryAdmin?: OrganizationPrimaryAdmin | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|
@ -273,6 +285,15 @@ export interface EventMicrosite {
|
||||||
eventName?: string | null;
|
eventName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SPA microsite theme presets (`website.theme` on public microsite payload). */
|
||||||
|
export type WebsiteThemeMode = 'simple' | 'gradient';
|
||||||
|
|
||||||
|
export interface WebsiteThemeConfig {
|
||||||
|
theme: WebsiteThemeMode;
|
||||||
|
color: string;
|
||||||
|
font?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventWebsiteSettings {
|
export interface EventWebsiteSettings {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
|
|
@ -293,6 +314,8 @@ export interface EventWebsiteSettings {
|
||||||
seoDescription?: string;
|
seoDescription?: string;
|
||||||
/** Visual editor markup; canonical with publish (also mirrored legacy on Home EventPage). */
|
/** Visual editor markup; canonical with publish (also mirrored legacy on Home EventPage). */
|
||||||
micrositeHtml?: string;
|
micrositeHtml?: string;
|
||||||
|
/** Luma-style theme: simple/gradient + color + font */
|
||||||
|
theme?: WebsiteThemeConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */
|
/** Event/session/quiz loyalty overrides (omit or null = inherit / unused at layer). */
|
||||||
|
|
@ -313,6 +336,12 @@ export interface ActivityStats {
|
||||||
participantCount: number;
|
participantCount: number;
|
||||||
quizPollCount: number;
|
quizPollCount: number;
|
||||||
responseCount: number;
|
responseCount: number;
|
||||||
|
/** Average star rating across all event feedback/ratings forms */
|
||||||
|
eventRatingAverage?: number;
|
||||||
|
eventRatingCount?: number;
|
||||||
|
/** Average star rating for the activity's primary session ratings form */
|
||||||
|
sessionRatingAverage?: number;
|
||||||
|
sessionRatingCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
|
|
@ -478,6 +507,13 @@ export interface DesignTemplate {
|
||||||
variables?: DesignTemplateVariable[];
|
variables?: DesignTemplateVariable[];
|
||||||
organization?: string;
|
organization?: string;
|
||||||
settings?: { isPublic?: boolean; isActive?: boolean; canDuplicate?: boolean };
|
settings?: { isPublic?: boolean; isActive?: boolean; canDuplicate?: boolean };
|
||||||
|
isSystemTemplate?: boolean;
|
||||||
|
isGlobalTemplate?: boolean;
|
||||||
|
isOrgOverride?: boolean;
|
||||||
|
globalTemplateKey?: string;
|
||||||
|
systemTemplateKey?: string;
|
||||||
|
derivedFromTemplate?: string;
|
||||||
|
canDelete?: boolean;
|
||||||
usedBy?: string[];
|
usedBy?: string[];
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|
@ -527,6 +563,8 @@ export interface FormField {
|
||||||
mapsTo?: string;
|
mapsTo?: string;
|
||||||
customMapsKey?: string;
|
customMapsKey?: string;
|
||||||
isFixed?: boolean;
|
isFixed?: boolean;
|
||||||
|
/** Registration: add submitted value(s) as participant tags. */
|
||||||
|
convertToTag?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormSettings {
|
export interface FormSettings {
|
||||||
|
|
@ -541,8 +579,20 @@ export interface FormSettings {
|
||||||
paymentEnabled?: boolean;
|
paymentEnabled?: boolean;
|
||||||
paymentAmount?: string | number;
|
paymentAmount?: string | number;
|
||||||
sendConfirmationEmail?: boolean;
|
sendConfirmationEmail?: boolean;
|
||||||
|
/** Emailer DesignTemplate id used for registration confirmation. */
|
||||||
|
confirmationEmailTemplateId?: string | null;
|
||||||
onSubmitAddTags?: string[];
|
onSubmitAddTags?: string[];
|
||||||
onSubmitParticipantListId?: string;
|
onSubmitParticipantListId?: string;
|
||||||
|
allowDuplicateEmail?: boolean;
|
||||||
|
allowDuplicatePhone?: boolean;
|
||||||
|
formClosedMessage?: string;
|
||||||
|
submitErrorMessage?: string;
|
||||||
|
captchaFailedMessage?: string;
|
||||||
|
registrationClosedMessage?: string;
|
||||||
|
registrationDeadlineMessage?: string;
|
||||||
|
registrationFullMessage?: string;
|
||||||
|
duplicateEmailMessage?: string;
|
||||||
|
duplicatePhoneMessage?: string;
|
||||||
eventHeader?: {
|
eventHeader?: {
|
||||||
showLogo?: boolean;
|
showLogo?: boolean;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
|
@ -601,6 +651,14 @@ export interface PublicFormDefinition {
|
||||||
showThankYou?: boolean;
|
showThankYou?: boolean;
|
||||||
collectEmail?: boolean;
|
collectEmail?: boolean;
|
||||||
allowAnonymous?: boolean;
|
allowAnonymous?: boolean;
|
||||||
|
formClosedMessage?: string;
|
||||||
|
submitErrorMessage?: string;
|
||||||
|
captchaFailedMessage?: string;
|
||||||
|
registrationClosedMessage?: string;
|
||||||
|
registrationDeadlineMessage?: string;
|
||||||
|
registrationFullMessage?: string;
|
||||||
|
duplicateEmailMessage?: string;
|
||||||
|
duplicatePhoneMessage?: string;
|
||||||
eventHeader?: FormSettings['eventHeader'];
|
eventHeader?: FormSettings['eventHeader'];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -795,7 +853,9 @@ export type MicrositeV2FieldType =
|
||||||
| 'image'
|
| 'image'
|
||||||
| 'video'
|
| 'video'
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'array';
|
| 'array'
|
||||||
|
| 'date'
|
||||||
|
| 'time';
|
||||||
|
|
||||||
export interface MicrositeV2FieldShowIf {
|
export interface MicrositeV2FieldShowIf {
|
||||||
field: string;
|
field: string;
|
||||||
|
|
|
||||||
|
|
@ -355,7 +355,11 @@ export function PublicFormView() {
|
||||||
await formsApi.submit(formId, { data });
|
await formsApi.submit(formId, { data });
|
||||||
setDone(true);
|
setDone(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSubmitError(e instanceof Error ? e.message : "Submit failed.");
|
setSubmitError(
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: form.settings?.submitErrorMessage?.trim() || "Submit failed.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -398,6 +402,7 @@ export function PublicFormView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (done) {
|
if (done) {
|
||||||
|
const showThanks = form.settings?.showThankYou !== false;
|
||||||
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
|
const msg = form.settings?.thankYouMessage?.trim() || "Thank you for your response.";
|
||||||
const ev = form.event;
|
const ev = form.event;
|
||||||
return (
|
return (
|
||||||
|
|
@ -436,6 +441,7 @@ export function PublicFormView() {
|
||||||
>
|
>
|
||||||
{form.name}
|
{form.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
{showThanks ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
|
@ -451,6 +457,7 @@ export function PublicFormView() {
|
||||||
>
|
>
|
||||||
{msg}
|
{msg}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue